Compare commits

..

No commits in common. "master" and "version-1.0.1" have entirely different histories.

43 changed files with 1270 additions and 9459 deletions

5
.gitignore vendored
View File

@ -1,10 +1,9 @@
*.pyc
*.swp
/*.blend*
*.blend
*.blend[1-9]
blender_cloud/wheels/*.whl
/textures*/
/test_*.py
/dist/
/build/
/addon-bundle/*.zip
__pycache__

View File

@ -1,228 +0,0 @@
# Blender Cloud changelog
## Version 1.25 (2022-02-25)
- Compatibility with Blender 3.1 (Python 3.10).
- Bump blender-asset-tracer to version 1.11, for UDIM support.
## Version 1.24 (2022-02-04)
- Bump blender-asset-tracer version 1.8 → 1.10, for fixing a bug where files were doubly-compressed.
## Version 1.23 (2021-11-09)
- Bump blender-asset-tracer version 1.7 → 1.8, for compatibility with sending read-only blend files to Flamenco.
## Version 1.22 (2021-11-05)
- Fix Windows incompatibility when using Shaman URLs as job storage path.
- Bump blender-asset-tracer version 1.6 → 1.7, for compatibility with files compressed by Blender 3.0.
## Version 1.21 (2021-07-27)
- Bump blender-asset-tracer version 1.5.1 → 1.6, for better compatibility with Geometry Nodes.
## Version 1.20 (2021-07-22)
- Bump blender-asset-tracer version 1.3.1 -> 1.5.1.
- Blender-asset-tracer "Strict Pointer Mode" disabled, to avoid issues with
not-entirely-synced library overrides.
## Version 1.19 (2021-02-23)
- Another Python 3.9+ compatibility fix.
## Version 1.18 (2021-02-16)
- Add compatibility with Python 3.9 (as used in Blender 2.93).
- Drop compatibility with Blender 2.79 and older. The last version of the
Blender Cloud add-on with 2.79 and older is version 1.17.
## Version 1.17 (2021-02-04)
- This is the last version compatible with Blender 2.77a - 2.79.
- Upgrade BAT to version 1.3.1, which brings compatibility with Geometry Nodes and
fixes some issues on Windows.
## Version 1.16 (2020-03-03)
- Fixed Windows compatibility issue with the handling of Shaman URLs.
## Version 1.15 (2019-12-12)
- Avoid creating BAT pack when the to-be-rendered file is already inside the job storage
directory. This assumes that the paths are already correct for the Flamenco Workers.
## Version 1.14 (2019-10-10)
- Upgraded BAT to 1.2 for missing smoke caches, compatibility with Blender 2.81, and some
Windows-specific fixes.
- Removed warnings on the terminal when running Blender 2.80+
## Version 1.13 (2019-04-18)
- Upgraded BAT to 1.1.1 for a compatibility fix with Blender 2.79
- Flamenco: Support for Flamenco Manager settings versioning + for settings version 2.
When using Blender Cloud Add-on 1.12 or older, Flamenco Server will automatically convert the
Manager settings to version 1.
- More Blender 2.80 compatibility fixes
## Version 1.12 (2019-03-25)
- Flamenco: Change how progressive render tasks are created. Instead of the artist setting a fixed
number of sample chunks, they can now set a maximum number of samples for each render task.
Initial render tasks are created with a low number of samples, and subsequent tasks have an
increasing number of samples, up to the set maximum. The total number of samples of the final
render is still equal to the number of samples configured in the blend file.
Requires Flamenco Server 2.2 or newer.
- Flamenco: Added a hidden "Submit & Quit" button. This button can be enabled in the add-on
preferences and and then be available on the Flamenco Render panel. Pressing the button will
silently close Blender after the job has been submitted to Flamenco (for example to click,
walk away, and free up memory for when the same machine is part of the render farm).
- Flamenco: Name render jobs just 'thefile' instead of 'Render thefile.flamenco.blend'.
This makes the job overview on Flamenco Server cleaner.
- Flamenco: support Shaman servers. See https://www.flamenco.io/docs/user_manual/shaman/
for more info.
- Flamenco: The 'blender-video-chunks' job type now also allows MP4 and MOV video containers.
## Version 1.11.1 (2019-01-04)
- Bundled missing Texture Browser icons.
## Version 1.11.0 (2019-01-04)
- Texture Browser now works on Blender 2.8.
- Blender Sync: Fixed compatibility issue with Blender 2.8.
## Version 1.10.0 (2019-01-02)
- Bundles Blender-Asset-Tracer 0.8.
- Fix crashing Blender when running in background mode (e.g. without GUI).
- Flamenco: Include extra job parameters to allow for encoding a video at the end of a render
job that produced an image sequence.
- Flamenco: Compress all blend files, and not just the one we save from Blender.
- Flamenco: Store more info in the `jobinfo.json` file. This is mostly useful for debugging issues
on the render farm, as now things like the exclusion filter and Manager settings are logged too.
- Flamenco: Allow BAT-packing of only those assets that are referred to by relative path (e.g.
a path starting with `//`). Assets with an absolute path are ignored, and assumed to be reachable
at the same path by the Workers.
- Flamenco: Added 'blender-video-chunks' job type, meant for rendering the edit of a film from the
VSE. This job type requires that the file is configured for rendering to Matroska video
files.
Audio is only extracted when there is an audio codec configured. This is a bit arbitrary, but it's
at least a way to tell whether the artist is considering that there is audio of any relevance in
the current blend file.
## Version 1.9.4 (2018-11-01)
- Fixed Python 3.6 and Blender 2.79b incompatibilities accidentally introduced in 1.9.3.
## Version 1.9.3 (2018-10-30)
- Fix drawing of Attract strips in the VSE on Blender 2.8.
## Version 1.9.2 (2018-09-17)
- No changes, just a different filename to force a refresh on our
hosting platform.
## Version 1.9.1 (2018-09-17)
- Fix issue with Python 3.7, which is used by current daily builds of Blender.
## Version 1.9 (2018-09-05)
- Last version to support Blender versions before 2.80!
- Replace BAM with BAT🦇.
- Don't crash the texture browser when an invalid texture is seen.
- Support colour strips as Attract shots.
- Flamenco: allow jobs to be created in 'paused' state.
- Flamenco: only show Flamenco Managers that are linked to the currently selected project.
## Version 1.8 (2018-01-03)
- Distinguish between 'please subscribe' (to get a new subscription) and 'please renew' (to renew an
existing subscription).
- When re-opening the Texture Browser it now opens in the same folder as where it was when closed.
- In the texture browser, draw the components of the texture (i.e. which map types are available),
such as 'bump, normal, specular'.
- Use Interface Scale setting from user preferences to draw the Texture Browser text.
- Store project-specific settings in the preferences, such as filesystem paths, for each project,
and restore those settings when the project is selected again. Does not touch settings that
haven't been set for the newly selected project. These settings are only saved when a setting
is updated, so to save your current settings need to update a single setting; this saves all
settings for the project.
- Added button in the User Preferences to open a Cloud project in your webbrowser.
## Version 1.7.5 (2017-10-06)
- Sorting the project list alphabetically.
- Renamed 'Job File Path' to 'Job Storage Path' so it's more explicit.
- Allow overriding the render output path on a per-scene basis.
## Version 1.7.4 (2017-09-05)
- Fix [T52621](https://developer.blender.org/T52621): Fixed class name collision upon add-on
registration. This is checked since Blender 2.79.
- Fix [T48852](https://developer.blender.org/T48852): Screenshot no longer shows "Communicating with
Blender Cloud".
## Version 1.7.3 (2017-08-08)
- Default to scene frame range when no frame range is given.
- Refuse to render on Flamenco before blend file is saved at least once.
- Fixed some Windows-specific issues.
## Version 1.7.2 (2017-06-22)
- Fixed compatibility with Blender 2.78c.
## Version 1.7.1 (2017-06-13)
- Fixed asyncio issues on Windows
## Version 1.7.0 (2017-06-09)
- Fixed reloading after upgrading from 1.4.4 (our last public release).
- Fixed bug handling a symlinked project path.
- Added support for Manager-defined path replacement variables.
## Version 1.6.4 (2017-04-21)
- Added file exclusion filter for Flamenco. A filter like `*.abc;*.mkv;*.mov` can be
used to prevent certain files from being copied to the job storage directory.
Requires a Blender that is bundled with BAM 1.1.7 or newer.
## Version 1.6.3 (2017-03-21)
- Fixed bug where local project path wasn't shown for projects only set up for Flamenco
(and not Attract).
- Added this CHANGELOG.md file, which will contain user-relevant changes.
## Version 1.6.2 (2017-03-17)
- Flamenco: when opening non-existing file path, open parent instead
- Fix T50954: Improve Blender Cloud add-on project selector
## Version 1.6.1 (2017-03-07)
- Show error in GUI when Blender Cloud is unreachable
- Fixed sample count when using branched path tracing
## Version 1.6.0 (2017-02-14)
- Default to frame chunk size of 1 (instead of 10).
- Turn off "use overwrite" and "use placeholder" for Flamenco blend files.
- Fixed bugs when blendfile is outside the project directory
## Older versions
For the history of older versions, please refer to the
[Git history](https://developer.blender.org/diffusion/BCA/)

View File

@ -1,57 +0,0 @@
# Flamenco
The Blender Cloud add-on has preliminary support for [Flamenco](https://flamenco.io/).
It requires a project on the [Blender Cloud](https://cloud.blender.org/) that is set up for
Flamenco, and it requires you to be logged in as a user with rights to use Flamenco.
## Quirks
Flamenco support is unpolished, so it has some quirks.
- Project selection happens through the Attract project selector. As a result, you can only
select Attract-enabled projects (even when they are not set up for Flamenco). Be careful
which project you select.
- The top level directory of the project is also set through the Attract properties.
- Defaults are heavily biased for our use in the Blender Institute.
- Settings that should be project-specific are not, i.e. are regular add-on preferences.
- Sending a project to Flamenco will check the "File Extensions" setting in the Output panel,
and save the blend file to the current filename.
## Render job file locations
Rendering via Flamenco roughly comprises of two steps:
1. Packing the file to render with its dependencies, and placing them in the "job file path".
2. Rendering, and placing the output files in the "job output path".
### Job file path
The "job file path" consists of the following path components:
1. The add-on preference "job file path", e.g. `/render/_flamenco/storage`
2. The current date and time, your Blender Cloud username, and the name of the current blend file.
3. The name of the current blend file.
For example:
`/render/_flamenco/storage/2017-01-18-104841.931387-sybren-03_02_A.layout/03_02_A.layout.blend`
### Job output path
The file path of output files consists of the following path components:
1. The add-on preference "job file path", e.g. `/render/agent327/frames`
2. The path of the current blend file, relative to the project directory. The first N components
of this path can be stripped; when N=1 it turns `scenes/03-searching/03_02_A-snooping/` into
`03-searching/03_02_A-snooping/`.
3. The name of the current blend file, without `.blend`.
4. The file name depends on the type of output:
- When rendering to image files: A 5-digit frame number with the required file extension.
- When rendering to a video file: The frame range with the required file extension.
For example:
`/render/agent327/frames/03-searching/03_02_A-snooping/03_02_A.layout/00441.exr`
`/render/agent327/frames/03-searching/03_02_A-snooping/03_02_A.layout/14-51,60-133.mkv`

View File

@ -24,8 +24,6 @@ Installing the addon
* If you don't have one already, sign up for an account at
the [Blender ID site](https://www.blender.org/id/).
* If you had a previous version of the addon installed, deactivate it
and restart Blender.
* Install and log in with the
[Blender ID addon](https://developer.blender.org/diffusion/BIA/).
* Install the Blender Cloud addon in Blender (User Preferences →

View File

@ -1,52 +0,0 @@
Blender Cloud Addon
===================
Congratulations on downloading the Blender Cloud addon. For your
convenience, we have bundled it with the Blender ID addon.
To use the Blender Cloud addon, perform the following steps:
- Use Blender (File, User Preferences, Addons, Install from file)
to install blender_id-x.x.x.addon.zip
- If you had a previous version of the Blender Cloud addon installed,
restart Blender now.
- Log in with your Blender ID.
- Use Blender to install blender_cloud-x.x.x.addon.zip
If you don't see the addon in the list, enable the Testing
category.
- Press Ctrl+Alt+Shift+A to start the texture browser.
- Visit the User Preferences, Addons panel, to use the Blender Sync
feature.
Support for Blenders not from blender.org
-----------------------------------------
Maybe you use Blender from another source than blender.org, such as an
Ubuntu package. If that is the case, you have to make sure that the
Python package "requests" is installed. On Ubuntu Linux this can be
done with the command
sudo apt-get install python3-requests
On other platforms & distributions this might be different.
Blender uses Python 3.5, so make sure you install the package for the
correct version of Python.
Subscribing to the Blender Cloud
--------------------------------
The Blender Sync feature is free to use for everybody with a Blender
ID account. In order to use the Texture Browser you need to have a
Blender Cloud subscription. If you didn't subscribe yet, go to:
https://cloud.blender.org/join

View File

@ -1,36 +0,0 @@
#!/bin/bash
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
cd $(dirname $(readlink -f $0))
BCLOUD=$(ls ../dist/blender_cloud-*.addon.zip | tail -n 1)
BID=$(ls ../../../blender-id-addon/dist/blender_id-*.addon.zip | tail -n 1)
[ -z "$BCLOUD" ] && echo "BCloud addon not found" >&2
[ -z "$BID" ] && echo "B'ID addon not found" >&2
cp -va $BCLOUD $BID .
BUNDLE=$(basename $BCLOUD)
BUNDLE=${BUNDLE/.addon.zip/-bundle-UNZIP_ME_FIRST.zip}
zip -9 $BUNDLE $(basename $BCLOUD) $(basename $BID) README.txt
dolphin --select $BUNDLE 2>/dev/null >/dev/null & disown
echo "CREATED: $BUNDLE"

View File

@ -19,22 +19,21 @@
# <pep8 compliant>
bl_info = {
"name": "Blender Cloud",
"author": "Sybren A. Stüvel, Francesco Siddi, Inês Almeida, Antony Riakiotakis",
"version": (1, 25),
"blender": (2, 80, 0),
"location": "Addon Preferences panel, and Ctrl+Shift+Alt+A anywhere for texture browser",
"description": "Texture library browser and Blender Sync. Requires the Blender ID addon "
"and Blender 2.80 or newer.",
"wiki_url": "https://wiki.blender.org/index.php/Extensions:2.6/Py/"
"Scripts/System/BlenderCloud",
"category": "System",
'name': 'Blender Cloud Texture Browser',
'author': 'Sybren A. Stüvel and Francesco Siddi',
'version': (1, 0, 1),
'blender': (2, 77, 0),
'location': 'Ctrl+Shift+Alt+A anywhere',
'description': 'Allows downloading of textures from the Blender Cloud. Requires '
'the Blender ID addon and Blender 2.77a or newer.',
'wiki_url': 'http://wiki.blender.org/index.php/Extensions:2.6/Py/'
'Scripts/System/BlenderCloud',
'category': 'System',
'support': 'TESTING'
}
import logging
# Support reloading
if "pillar" in locals():
if 'pillar' in locals():
import importlib
wheels = importlib.reload(wheels)
@ -44,111 +43,43 @@ if "pillar" in locals():
cache = importlib.reload(cache)
else:
from . import wheels
wheels.load_wheels()
from . import pillar, cache
log = logging.getLogger(__name__)
def register():
"""Late-loads and registers the Blender-dependent submodules."""
import sys
_monkey_patch_requests()
# Support reloading
if "%s.blender" % __name__ in sys.modules:
if '%s.blender' % __name__ in sys.modules:
import importlib
def reload_mod(name):
modname = "%s.%s" % (__name__, name)
try:
old_module = sys.modules[modname]
except KeyError:
# Wasn't loaded before -- can happen after an upgrade.
new_module = importlib.import_module(modname)
modname = '%s.%s' % (__name__, name)
module = importlib.reload(sys.modules[modname])
sys.modules[modname] = module
return module
blender = reload_mod('blender')
gui = reload_mod('gui')
async_loop = reload_mod('async_loop')
else:
new_module = importlib.reload(old_module)
sys.modules[modname] = new_module
return new_module
reload_mod("blendfile")
reload_mod("home_project")
reload_mod("utils")
reload_mod("pillar")
async_loop = reload_mod("async_loop")
flamenco = reload_mod("flamenco")
attract = reload_mod("attract")
texture_browser = reload_mod("texture_browser")
settings_sync = reload_mod("settings_sync")
image_sharing = reload_mod("image_sharing")
blender = reload_mod("blender")
project_specific = reload_mod("project_specific")
else:
from . import (
blender,
texture_browser,
async_loop,
settings_sync,
blendfile,
home_project,
image_sharing,
attract,
flamenco,
project_specific,
)
from . import blender, gui, async_loop
async_loop.setup_asyncio_executor()
async_loop.register()
flamenco.register()
attract.register()
texture_browser.register()
settings_sync.register()
image_sharing.register()
blender.register()
project_specific.handle_project_update()
def _monkey_patch_requests():
"""Monkey-patch old versions of Requests.
This is required for the Mac version of Blender 2.77a.
"""
import requests
if requests.__build__ >= 0x020601:
return
log.info("Monkey-patching requests version %s", requests.__version__)
from requests.packages.urllib3.response import HTTPResponse
HTTPResponse.chunked = False
HTTPResponse.chunk_left = None
gui.register()
def unregister():
from . import (
blender,
texture_browser,
async_loop,
settings_sync,
image_sharing,
attract,
flamenco,
)
from . import blender, gui, async_loop
image_sharing.unregister()
attract.unregister()
settings_sync.unregister()
gui.unregister()
blender.unregister()
texture_browser.unregister()
async_loop.unregister()
flamenco.unregister()

View File

@ -14,7 +14,7 @@ See <http://github.com/ActiveState/appdirs> for details and usage.
# - XDG spec for Un*x: http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
__version_info__ = (1, 4, 0)
__version__ = ".".join(map(str, __version_info__))
__version__ = '.'.join(map(str, __version_info__))
import sys
@ -25,23 +25,23 @@ PY3 = sys.version_info[0] == 3
if PY3:
unicode = str
if sys.platform.startswith("java"):
if sys.platform.startswith('java'):
import platform
os_name = platform.java_ver()[3][0]
if os_name.startswith("Windows"): # "Windows XP", "Windows 7", etc.
system = "win32"
elif os_name.startswith("Mac"): # "Mac OS X", etc.
system = "darwin"
if os_name.startswith('Windows'): # "Windows XP", "Windows 7", etc.
system = 'win32'
elif os_name.startswith('Mac'): # "Mac OS X", etc.
system = 'darwin'
else: # "Linux", "SunOS", "FreeBSD", etc.
# Setting this to "linux2" is not ideal, but only Windows or Mac
# are actually checked for and the rest of the module expects
# *sys.platform* style strings.
system = "linux2"
system = 'linux2'
else:
system = sys.platform
def user_data_dir(appname=None, appauthor=None, version=None, roaming=False):
r"""Return full path to the user-specific data dir for this application.
@ -84,12 +84,12 @@ def user_data_dir(appname=None, appauthor=None, version=None, roaming=False):
path = os.path.join(path, appauthor, appname)
else:
path = os.path.join(path, appname)
elif system == "darwin":
path = os.path.expanduser("~/Library/Application Support/")
elif system == 'darwin':
path = os.path.expanduser('~/Library/Application Support/')
if appname:
path = os.path.join(path, appname)
else:
path = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
path = os.getenv('XDG_DATA_HOME', os.path.expanduser("~/.local/share"))
if appname:
path = os.path.join(path, appname)
if appname and version:
@ -137,19 +137,16 @@ def site_data_dir(appname=None, appauthor=None, version=None, multipath=False):
path = os.path.join(path, appauthor, appname)
else:
path = os.path.join(path, appname)
elif system == "darwin":
path = os.path.expanduser("/Library/Application Support")
elif system == 'darwin':
path = os.path.expanduser('/Library/Application Support')
if appname:
path = os.path.join(path, appname)
else:
# XDG default for $XDG_DATA_DIRS
# only first, if multipath is False
path = os.getenv(
"XDG_DATA_DIRS", os.pathsep.join(["/usr/local/share", "/usr/share"])
)
pathlist = [
os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)
]
path = os.getenv('XDG_DATA_DIRS',
os.pathsep.join(['/usr/local/share', '/usr/share']))
pathlist = [os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)]
if appname:
if version:
appname = os.path.join(appname, version)
@ -198,7 +195,7 @@ def user_config_dir(appname=None, appauthor=None, version=None, roaming=False):
if system in ["win32", "darwin"]:
path = user_data_dir(appname, appauthor, None, roaming)
else:
path = os.getenv("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
path = os.getenv('XDG_CONFIG_HOME', os.path.expanduser("~/.config"))
if appname:
path = os.path.join(path, appname)
if appname and version:
@ -243,10 +240,8 @@ def site_config_dir(appname=None, appauthor=None, version=None, multipath=False)
else:
# XDG default for $XDG_CONFIG_DIRS
# only first, if multipath is False
path = os.getenv("XDG_CONFIG_DIRS", "/etc/xdg")
pathlist = [
os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)
]
path = os.getenv('XDG_CONFIG_DIRS', '/etc/xdg')
pathlist = [os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)]
if appname:
if version:
appname = os.path.join(appname, version)
@ -303,14 +298,14 @@ def user_cache_dir(appname=None, appauthor=None, version=None, opinion=True):
path = os.path.join(path, appname)
if opinion:
path = os.path.join(path, "Cache")
elif system == "darwin":
path = os.path.expanduser("~/Library/Caches")
elif system == 'darwin':
path = os.path.expanduser('~/Library/Caches')
if appname:
path = os.path.join(path, appname)
else:
path = os.getenv("XDG_CACHE_HOME", os.path.expanduser("~/.cache"))
path = os.getenv('XDG_CACHE_HOME', os.path.expanduser('~/.cache'))
if appname:
path = os.path.join(path, appname.lower().replace(" ", "-"))
path = os.path.join(path, appname.lower().replace(' ', '-'))
if appname and version:
path = os.path.join(path, version)
return path
@ -349,7 +344,9 @@ def user_log_dir(appname=None, appauthor=None, version=None, opinion=True):
This can be disabled with the `opinion=False` option.
"""
if system == "darwin":
path = os.path.join(os.path.expanduser("~/Library/Logs"), appname)
path = os.path.join(
os.path.expanduser('~/Library/Logs'),
appname)
elif system == "win32":
path = user_data_dir(appname, appauthor, version)
version = False
@ -367,10 +364,8 @@ def user_log_dir(appname=None, appauthor=None, version=None, opinion=True):
class AppDirs(object):
"""Convenience wrapper for getting application dirs."""
def __init__(
self, appname, appauthor=None, version=None, roaming=False, multipath=False
):
def __init__(self, appname, appauthor=None, version=None, roaming=False,
multipath=False):
self.appname = appname
self.appauthor = appauthor
self.version = version
@ -379,40 +374,37 @@ class AppDirs(object):
@property
def user_data_dir(self):
return user_data_dir(
self.appname, self.appauthor, version=self.version, roaming=self.roaming
)
return user_data_dir(self.appname, self.appauthor,
version=self.version, roaming=self.roaming)
@property
def site_data_dir(self):
return site_data_dir(
self.appname, self.appauthor, version=self.version, multipath=self.multipath
)
return site_data_dir(self.appname, self.appauthor,
version=self.version, multipath=self.multipath)
@property
def user_config_dir(self):
return user_config_dir(
self.appname, self.appauthor, version=self.version, roaming=self.roaming
)
return user_config_dir(self.appname, self.appauthor,
version=self.version, roaming=self.roaming)
@property
def site_config_dir(self):
return site_config_dir(
self.appname, self.appauthor, version=self.version, multipath=self.multipath
)
return site_config_dir(self.appname, self.appauthor,
version=self.version, multipath=self.multipath)
@property
def user_cache_dir(self):
return user_cache_dir(self.appname, self.appauthor, version=self.version)
return user_cache_dir(self.appname, self.appauthor,
version=self.version)
@property
def user_log_dir(self):
return user_log_dir(self.appname, self.appauthor, version=self.version)
return user_log_dir(self.appname, self.appauthor,
version=self.version)
#---- internal support stuff
def _get_win_folder_from_registry(csidl_name):
"""This is a fallback technique at best. I'm not sure if using the
registry for this guarantees us the correct answer for all CSIDL_*
@ -428,7 +420,7 @@ def _get_win_folder_from_registry(csidl_name):
key = _winreg.OpenKey(
_winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders",
r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders"
)
dir, type = _winreg.QueryValueEx(key, shell_folder_name)
return dir
@ -436,7 +428,6 @@ def _get_win_folder_from_registry(csidl_name):
def _get_win_folder_with_pywin32(csidl_name):
from win32com.shell import shellcon, shell
dir = shell.SHGetFolderPath(0, getattr(shellcon, csidl_name), 0, 0)
# Try to make this a unicode path because SHGetFolderPath does
# not return unicode strings when there is unicode data in the
@ -454,7 +445,6 @@ def _get_win_folder_with_pywin32(csidl_name):
if has_high_char:
try:
import win32api
dir = win32api.GetShortPathName(dir)
except ImportError:
pass
@ -489,22 +479,15 @@ def _get_win_folder_with_ctypes(csidl_name):
return buf.value
def _get_win_folder_with_jna(csidl_name):
import array
from com.sun import jna
from com.sun.jna.platform import win32
buf_size = win32.WinDef.MAX_PATH * 2
buf = array.zeros("c", buf_size)
buf = array.zeros('c', buf_size)
shell = win32.Shell32.INSTANCE
shell.SHGetFolderPath(
None,
getattr(win32.ShlObj, csidl_name),
None,
win32.ShlObj.SHGFP_TYPE_CURRENT,
buf,
)
shell.SHGetFolderPath(None, getattr(win32.ShlObj, csidl_name), None, win32.ShlObj.SHGFP_TYPE_CURRENT, buf)
dir = jna.Native.toString(buf.tostring()).rstrip("\0")
# Downgrade to short path name if have highbit chars. See
@ -515,28 +498,24 @@ def _get_win_folder_with_jna(csidl_name):
has_high_char = True
break
if has_high_char:
buf = array.zeros("c", buf_size)
buf = array.zeros('c', buf_size)
kernel = win32.Kernel32.INSTANCE
if kernal.GetShortPathName(dir, buf, buf_size):
dir = jna.Native.toString(buf.tostring()).rstrip("\0")
return dir
if system == "win32":
try:
import win32com.shell
_get_win_folder = _get_win_folder_with_pywin32
except ImportError:
try:
from ctypes import windll # type: ignore
from ctypes import windll
_get_win_folder = _get_win_folder_with_ctypes
except ImportError:
try:
import com.sun.jna
_get_win_folder = _get_win_folder_with_jna
except ImportError:
_get_win_folder = _get_win_folder_from_registry
@ -548,14 +527,9 @@ if __name__ == "__main__":
appname = "MyApp"
appauthor = "MyCompany"
props = (
"user_data_dir",
"site_data_dir",
"user_config_dir",
"site_config_dir",
"user_cache_dir",
"user_log_dir",
)
props = ("user_data_dir", "site_data_dir",
"user_config_dir", "site_config_dir",
"user_cache_dir", "user_log_dir")
print("-- app dirs (with optional 'version')")
dirs = AppDirs(appname, appauthor, version="1.0")

View File

@ -1,21 +1,3 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
"""Manages the asyncio loop."""
import asyncio
@ -23,7 +5,6 @@ import traceback
import concurrent.futures
import logging
import gc
import typing
import bpy
@ -34,34 +15,18 @@ _loop_kicking_operator_running = False
def setup_asyncio_executor():
"""Sets up AsyncIO to run properly on each platform."""
"""Sets up AsyncIO to run on a single thread.
import sys
This ensures that only one Pillar HTTP call is performed at the same time. Other
calls that could be performed in parallel are queued, and thus we can
reliably cancel them.
"""
if sys.platform == "win32":
asyncio.get_event_loop().close()
# On Windows, the default event loop is SelectorEventLoop, which does
# not support subprocesses. ProactorEventLoop should be used instead.
# Source: https://docs.python.org/3/library/asyncio-subprocess.html
#
# NOTE: this is actually the default even loop in Python 3.9+.
loop = asyncio.ProactorEventLoop()
asyncio.set_event_loop(loop)
else:
executor = concurrent.futures.ThreadPoolExecutor()
loop = asyncio.get_event_loop()
executor = concurrent.futures.ThreadPoolExecutor(max_workers=10)
loop.set_default_executor(executor)
# loop.set_debug(True)
from . import pillar
# Python 3.8 deprecated the 'loop' parameter, 3.10 removed it.
kwargs = {"loop": loop} if sys.version_info < (3, 8) else {}
# No more than this many Pillar calls should be made simultaneously
pillar.pillar_semaphore = asyncio.Semaphore(3, **kwargs)
def kick_async_loop(*args) -> bool:
"""Performs a single iteration of the asyncio event loop.
@ -76,23 +41,17 @@ def kick_async_loop(*args) -> bool:
stop_after_this_kick = False
if loop.is_closed():
log.warning("loop closed, stopping immediately.")
log.warning('loop closed, stopping immediately.')
return True
# Passing an explicit loop is required. Without it, the function uses
# asyncio.get_running_loop(), which raises a RuntimeError as the current
# loop isn't running.
all_tasks = asyncio.all_tasks(loop=loop)
all_tasks = asyncio.Task.all_tasks()
if not len(all_tasks):
log.debug("no more scheduled tasks, stopping after this kick.")
log.debug('no more scheduled tasks, stopping after this kick.')
stop_after_this_kick = True
elif all(task.done() for task in all_tasks):
log.debug(
"all %i tasks are done, fetching results and stopping after this kick.",
len(all_tasks),
)
log.debug('all %i tasks are done, fetching results and stopping after this kick.',
len(all_tasks))
stop_after_this_kick = True
# Clean up circular references between tasks.
@ -105,12 +64,12 @@ def kick_async_loop(*args) -> bool:
# noinspection PyBroadException
try:
res = task.result()
log.debug(" task #%i: result=%r", task_idx, res)
log.debug(' task #%i: result=%r', task_idx, res)
except asyncio.CancelledError:
# No problem, we want to stop anyway.
log.debug(" task #%i: cancelled", task_idx)
log.debug(' task #%i: cancelled', task_idx)
except Exception:
print("{}: resulted in exception".format(task))
print('{}: resulted in exception'.format(task))
traceback.print_exc()
# for ref in gc.get_referrers(task):
@ -123,34 +82,17 @@ def kick_async_loop(*args) -> bool:
def ensure_async_loop():
log.debug("Starting asyncio loop")
log.debug('Starting asyncio loop')
result = bpy.ops.asyncio.loop()
log.debug("Result of starting modal operator is %r", result)
def erase_async_loop():
global _loop_kicking_operator_running
log.debug("Erasing async loop")
loop = asyncio.get_event_loop()
loop.stop()
log.debug('Result of starting modal operator is %r', result)
class AsyncLoopModalOperator(bpy.types.Operator):
bl_idname = "asyncio.loop"
bl_label = "Runs the asyncio main loop"
bl_idname = 'asyncio.loop'
bl_label = 'Runs the asyncio main loop'
timer = None
log = logging.getLogger(__name__ + ".AsyncLoopModalOperator")
def __del__(self):
global _loop_kicking_operator_running
# This can be required when the operator is running while Blender
# (re)loads a file. The operator then doesn't get the chance to
# finish the async tasks, hence stop_after_this_kick is never True.
_loop_kicking_operator_running = False
log = logging.getLogger(__name__ + '.AsyncLoopModalOperator')
def execute(self, context):
return self.invoke(context, None)
@ -159,28 +101,22 @@ class AsyncLoopModalOperator(bpy.types.Operator):
global _loop_kicking_operator_running
if _loop_kicking_operator_running:
self.log.debug("Another loop-kicking operator is already running.")
return {"PASS_THROUGH"}
self.log.debug('Another loop-kicking operator is already running.')
return {'PASS_THROUGH'}
context.window_manager.modal_handler_add(self)
_loop_kicking_operator_running = True
wm = context.window_manager
self.timer = wm.event_timer_add(0.00001, window=context.window)
self.timer = wm.event_timer_add(0.00001, context.window)
return {"RUNNING_MODAL"}
return {'RUNNING_MODAL'}
def modal(self, context, event):
global _loop_kicking_operator_running
# If _loop_kicking_operator_running is set to False, someone called
# erase_async_loop(). This is a signal that we really should stop
# running.
if not _loop_kicking_operator_running:
return {"FINISHED"}
if event.type != "TIMER":
return {"PASS_THROUGH"}
if event.type != 'TIMER':
return {'PASS_THROUGH'}
# self.log.debug('KICKING LOOP')
stop_after_this_kick = kick_async_loop()
@ -188,120 +124,10 @@ class AsyncLoopModalOperator(bpy.types.Operator):
context.window_manager.event_timer_remove(self.timer)
_loop_kicking_operator_running = False
self.log.debug("Stopped asyncio loop kicking")
return {"FINISHED"}
self.log.debug('Stopped asyncio loop kicking')
return {'FINISHED'}
return {"RUNNING_MODAL"}
# noinspection PyAttributeOutsideInit
class AsyncModalOperatorMixin:
async_task = None # asyncio task for fetching thumbnails
signalling_future = (
None # asyncio future for signalling that we want to cancel everything.
)
log = logging.getLogger("%s.AsyncModalOperatorMixin" % __name__)
_state = "INITIALIZING"
stop_upon_exception = False
def invoke(self, context, event):
context.window_manager.modal_handler_add(self)
self.timer = context.window_manager.event_timer_add(
1 / 15, window=context.window
)
self.log.info("Starting")
self._new_async_task(self.async_execute(context))
return {"RUNNING_MODAL"}
async def async_execute(self, context):
"""Entry point of the asynchronous operator.
Implement in a subclass.
"""
return
def quit(self):
"""Signals the state machine to stop this operator from running."""
self._state = "QUIT"
def execute(self, context):
return self.invoke(context, None)
def modal(self, context, event):
task = self.async_task
if self._state != "EXCEPTION" and task and task.done() and not task.cancelled():
ex = task.exception()
if ex is not None:
self._state = "EXCEPTION"
self.log.error("Exception while running task: %s", ex)
if self.stop_upon_exception:
self.quit()
self._finish(context)
return {"FINISHED"}
return {"RUNNING_MODAL"}
if self._state == "QUIT":
self._finish(context)
return {"FINISHED"}
return {"PASS_THROUGH"}
def _finish(self, context):
self._stop_async_task()
context.window_manager.event_timer_remove(self.timer)
def _new_async_task(
self, async_task: typing.Coroutine, future: asyncio.Future = None
):
"""Stops the currently running async task, and starts another one."""
self.log.debug(
"Setting up a new task %r, so any existing task must be stopped", async_task
)
self._stop_async_task()
# Download the previews asynchronously.
self.signalling_future = future or asyncio.Future()
self.async_task = asyncio.ensure_future(async_task)
self.log.debug("Created new task %r", self.async_task)
# Start the async manager so everything happens.
ensure_async_loop()
def _stop_async_task(self):
self.log.debug("Stopping async task")
if self.async_task is None:
self.log.debug("No async task, trivially stopped")
return
# Signal that we want to stop.
self.async_task.cancel()
if not self.signalling_future.done():
self.log.info("Signalling that we want to cancel anything that's running.")
self.signalling_future.cancel()
# Wait until the asynchronous task is done.
if not self.async_task.done():
self.log.info("blocking until async task is done.")
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(self.async_task)
except asyncio.CancelledError:
self.log.info("Asynchronous task was cancelled")
return
# noinspection PyBroadException
try:
self.async_task.result() # This re-raises any exception of the task.
except asyncio.CancelledError:
self.log.info("Asynchronous task was cancelled")
except Exception:
self.log.exception("Exception from asynchronous task")
return {'RUNNING_MODAL'}
def register():

File diff suppressed because it is too large Load Diff

View File

@ -1,261 +0,0 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
import logging
import typing
import bpy
import bgl
import gpu
log = logging.getLogger(__name__)
strip_status_colour = {
None: (0.7, 0.7, 0.7),
"approved": (0.6392156862745098, 0.8784313725490196, 0.30196078431372547),
"final": (0.9058823529411765, 0.9607843137254902, 0.8274509803921568),
"in_progress": (1.0, 0.7450980392156863, 0.0),
"on_hold": (0.796078431372549, 0.6196078431372549, 0.08235294117647059),
"review": (0.8941176470588236, 0.9607843137254902, 0.9764705882352941),
"todo": (1.0, 0.5019607843137255, 0.5019607843137255),
}
CONFLICT_COLOUR = (0.576, 0.118, 0.035, 1.0) # RGBA tuple
gpu_vertex_shader = """
uniform mat4 ModelViewProjectionMatrix;
layout (location = 0) in vec2 pos;
layout (location = 1) in vec4 color;
out vec4 lineColor; // output to the fragment shader
void main()
{
gl_Position = ModelViewProjectionMatrix * vec4(pos.x, pos.y, 0.0, 1.0);
lineColor = color;
}
"""
gpu_fragment_shader = """
out vec4 fragColor;
in vec4 lineColor;
void main()
{
fragColor = lineColor;
}
"""
Float2 = typing.Tuple[float, float]
Float3 = typing.Tuple[float, float, float]
Float4 = typing.Tuple[float, float, float, float]
class AttractLineDrawer:
def __init__(self):
self._format = gpu.types.GPUVertFormat()
self._pos_id = self._format.attr_add(
id="pos", comp_type="F32", len=2, fetch_mode="FLOAT"
)
self._color_id = self._format.attr_add(
id="color", comp_type="F32", len=4, fetch_mode="FLOAT"
)
self.shader = gpu.types.GPUShader(gpu_vertex_shader, gpu_fragment_shader)
def draw(self, coords: typing.List[Float2], colors: typing.List[Float4]):
if not coords:
return
bgl.glEnable(bgl.GL_BLEND)
bgl.glLineWidth(2.0)
vbo = gpu.types.GPUVertBuf(len=len(coords), format=self._format)
vbo.attr_fill(id=self._pos_id, data=coords)
vbo.attr_fill(id=self._color_id, data=colors)
batch = gpu.types.GPUBatch(type="LINES", buf=vbo)
batch.program_set(self.shader)
batch.draw()
def get_strip_rectf(strip) -> Float4:
# Get x and y in terms of the grid's frames and channels
x1 = strip.frame_final_start
x2 = strip.frame_final_end
y1 = strip.channel + 0.2
y2 = strip.channel - 0.2 + 1
return x1, y1, x2, y2
def underline_in_strip(
strip_coords: Float4,
pixel_size_x: float,
color: Float4,
out_coords: typing.List[Float2],
out_colors: typing.List[Float4],
):
# Strip coords
s_x1, s_y1, s_x2, s_y2 = strip_coords
# be careful not to draw over the current frame line
cf_x = bpy.context.scene.frame_current_final
# TODO(Sybren): figure out how to pass one colour per line,
# instead of one colour per vertex.
out_coords.append((s_x1, s_y1))
out_colors.append(color)
if s_x1 < cf_x < s_x2:
# Bad luck, the line passes our strip, so draw two lines.
out_coords.append((cf_x - pixel_size_x, s_y1))
out_colors.append(color)
out_coords.append((cf_x + pixel_size_x, s_y1))
out_colors.append(color)
out_coords.append((s_x2, s_y1))
out_colors.append(color)
def strip_conflict(
strip_coords: Float4,
out_coords: typing.List[Float2],
out_colors: typing.List[Float4],
):
"""Draws conflicting states between strips."""
s_x1, s_y1, s_x2, s_y2 = strip_coords
# TODO(Sybren): draw a rectangle instead of a line.
out_coords.append((s_x1, s_y2))
out_colors.append(CONFLICT_COLOUR)
out_coords.append((s_x2, s_y1))
out_colors.append(CONFLICT_COLOUR)
out_coords.append((s_x2, s_y2))
out_colors.append(CONFLICT_COLOUR)
out_coords.append((s_x1, s_y1))
out_colors.append(CONFLICT_COLOUR)
def draw_callback_px(line_drawer: AttractLineDrawer):
context = bpy.context
if not context.scene.sequence_editor:
return
from . import shown_strips
region = context.region
xwin1, ywin1 = region.view2d.region_to_view(0, 0)
xwin2, ywin2 = region.view2d.region_to_view(region.width, region.height)
one_pixel_further_x, one_pixel_further_y = region.view2d.region_to_view(1, 1)
pixel_size_x = one_pixel_further_x - xwin1
strips = shown_strips(context)
coords = [] # type: typing.List[Float2]
colors = [] # type: typing.List[Float4]
# Collect all the lines (vertex coords + vertex colours) to draw.
for strip in strips:
if not strip.atc_object_id:
continue
# Get corners (x1, y1), (x2, y2) of the strip rectangle in px region coords
strip_coords = get_strip_rectf(strip)
# check if any of the coordinates are out of bounds
if (
strip_coords[0] > xwin2
or strip_coords[2] < xwin1
or strip_coords[1] > ywin2
or strip_coords[3] < ywin1
):
continue
# Draw
status = strip.atc_status
if status in strip_status_colour:
color = strip_status_colour[status]
else:
color = strip_status_colour[None]
alpha = 1.0 if strip.atc_is_synced else 0.5
underline_in_strip(strip_coords, pixel_size_x, color + (alpha,), coords, colors)
if strip.atc_is_synced and strip.atc_object_id_conflict:
strip_conflict(strip_coords, coords, colors)
line_drawer.draw(coords, colors)
def tag_redraw_all_sequencer_editors():
context = bpy.context
# Py cant access notifiers
for window in context.window_manager.windows:
for area in window.screen.areas:
if area.type == "SEQUENCE_EDITOR":
for region in area.regions:
if region.type == "WINDOW":
region.tag_redraw()
# This is a list so it can be changed instead of set
# if it is only changed, it does not have to be declared as a global everywhere
cb_handle = []
def callback_enable():
if cb_handle:
return
# Doing GPU stuff in the background crashes Blender, so let's not.
if bpy.app.background:
return
line_drawer = AttractLineDrawer()
cb_handle[:] = (
bpy.types.SpaceSequenceEditor.draw_handler_add(
draw_callback_px, (line_drawer,), "WINDOW", "POST_VIEW"
),
)
tag_redraw_all_sequencer_editors()
def callback_disable():
if not cb_handle:
return
try:
bpy.types.SpaceSequenceEditor.draw_handler_remove(cb_handle[0], "WINDOW")
except ValueError:
# Thrown when already removed.
pass
cb_handle.clear()
tag_redraw_all_sequencer_editors()

View File

@ -1,280 +1,47 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
"""Blender-specific code.
Separated from __init__.py so that we can import & run from non-Blender environments.
"""
import functools
import logging
import os.path
import tempfile
import bpy
from bpy.types import AddonPreferences, Operator, WindowManager, Scene, PropertyGroup
from bpy.props import (
StringProperty,
EnumProperty,
PointerProperty,
BoolProperty,
IntProperty,
)
import rna_prop_ui
from bpy.types import AddonPreferences, Operator, WindowManager, Scene
from bpy.props import StringProperty
from . import pillar, async_loop, flamenco, project_specific
from .utils import pyside_cache, redraw
from . import pillar, gui
PILLAR_WEB_SERVER_URL = os.environ.get("BCLOUD_SERVER", "https://cloud.blender.org/")
PILLAR_SERVER_URL = "%sapi/" % PILLAR_WEB_SERVER_URL
PILLAR_SERVER_URL = 'https://cloudapi.blender.org/'
# PILLAR_SERVER_URL = 'http://localhost:5000/'
ADDON_NAME = "blender_cloud"
ADDON_NAME = 'blender_cloud'
log = logging.getLogger(__name__)
icons = None
@pyside_cache
def blender_syncable_versions(self, context):
"""Returns the list of items used by SyncStatusProperties.version EnumProperty."""
bss = context.window_manager.blender_sync_status
versions = bss.available_blender_versions
if not versions:
return [("", "No settings stored in your Blender Cloud", "")]
return [(v, v, "") for v in versions]
class SyncStatusProperties(PropertyGroup):
status: EnumProperty(
items=[
("NONE", "NONE", "We have done nothing at all yet."),
(
"IDLE",
"IDLE",
"User requested something, which is done, and we are now idle.",
),
("SYNCING", "SYNCING", "Synchronising with Blender Cloud."),
],
name="status",
description="Current status of Blender Sync",
update=redraw,
)
version: EnumProperty(
items=blender_syncable_versions,
name="Version of Blender from which to pull",
description="Version of Blender from which to pull",
)
message: StringProperty(name="message", update=redraw)
level: EnumProperty(
items=[
("INFO", "INFO", ""),
("WARNING", "WARNING", ""),
("ERROR", "ERROR", ""),
("SUBSCRIBE", "SUBSCRIBE", ""),
],
name="level",
update=redraw,
)
def report(self, level: set, message: str):
assert len(level) == 1, "level should be a set of one string, not %r" % level
self.level = level.pop()
self.message = message
# Message can also be empty, just to erase it from the GUI.
# No need to actually log those.
if message:
try:
loglevel = logging._nameToLevel[self.level]
except KeyError:
loglevel = logging.WARNING
log.log(loglevel, message)
# List of syncable versions is stored in 'available_blender_versions' ID property,
# because I don't know how to store a variable list of strings in a proper RNA property.
@property
def available_blender_versions(self) -> list:
return self.get("available_blender_versions", [])
@available_blender_versions.setter
def available_blender_versions(self, new_versions):
self["available_blender_versions"] = new_versions
@pyside_cache
def bcloud_available_projects(self, context):
"""Returns the list of items used by BlenderCloudProjectGroup.project EnumProperty."""
projs = preferences().project.available_projects
if not projs:
return [("", "No projects available in your Blender Cloud", "")]
return [(p["_id"], p["name"], "") for p in projs]
@functools.lru_cache(1)
def project_extensions(project_id) -> set:
"""Returns the extensions the project is enabled for.
At the moment of writing these are 'attract' and 'flamenco'.
"""
log.debug("Finding extensions for project %s", project_id)
# We can't use our @property, since the preferences may be loaded from a
# preferences blend file, in which case it is not constructed from Python code.
available_projects = preferences().project.get("available_projects", [])
if not available_projects:
log.debug("No projects available.")
return set()
proj = next((p for p in available_projects if p["_id"] == project_id), None)
if proj is None:
log.debug("Project %s not found in available projects.", project_id)
return set()
return set(proj.get("enabled_for", ()))
class BlenderCloudProjectGroup(PropertyGroup):
status: EnumProperty(
items=[
("NONE", "NONE", "We have done nothing at all yet"),
(
"IDLE",
"IDLE",
"User requested something, which is done, and we are now idle",
),
("FETCHING", "FETCHING", "Fetching available projects from Blender Cloud"),
],
name="status",
update=redraw,
)
project: EnumProperty(
items=bcloud_available_projects,
name="Cloud project",
description="Which Blender Cloud project to work with",
update=project_specific.handle_project_update,
)
# List of projects is stored in 'available_projects' ID property,
# because I don't know how to store a variable list of strings in a proper RNA property.
@property
def available_projects(self) -> list:
return self.get("available_projects", [])
@available_projects.setter
def available_projects(self, new_projects):
self["available_projects"] = new_projects
project_specific.handle_project_update()
class BlenderCloudPreferences(AddonPreferences):
bl_idname = ADDON_NAME
# The following property is read-only to limit the scope of the
# The following two properties are read-only to limit the scope of the
# addon and allow for proper testing within this scope.
pillar_server: StringProperty(
name="Blender Cloud Server",
description="URL of the Blender Cloud backend server",
pillar_server = bpy.props.StringProperty(
name='Blender Cloud Server',
description='URL of the Blender Cloud backend server',
default=PILLAR_SERVER_URL,
get=lambda self: PILLAR_SERVER_URL,
get=lambda self: PILLAR_SERVER_URL
)
local_texture_dir: StringProperty(
name="Default Blender Cloud Texture Storage Directory",
subtype="DIR_PATH",
default="//textures",
# TODO: Move to the Scene properties?
project_uuid = bpy.props.StringProperty(
name='Project UUID',
description='UUID of the current Blender Cloud project',
default='5672beecc0261b2005ed1a33',
get=lambda self: '5672beecc0261b2005ed1a33'
)
open_browser_after_share: BoolProperty(
name="Open Browser after Sharing File",
description="When enabled, Blender will open a webbrowser",
default=True,
)
# TODO: store project-dependent properties with the project, so that people
# can switch projects and the Attract and Flamenco properties switch with it.
project: PointerProperty(type=BlenderCloudProjectGroup)
cloud_project_local_path: StringProperty(
name="Local Project Path",
description="Local path of your Attract project, used to search for blend files; "
"usually best to set to an absolute path",
subtype="DIR_PATH",
default="//../",
update=project_specific.store,
)
flamenco_manager: PointerProperty(type=flamenco.FlamencoManagerGroup)
flamenco_exclude_filter: StringProperty(
name="File Exclude Filter",
description='Space-separated list of filename filters, like "*.abc *.mkv", to prevent '
"matching files from being packed into the output directory",
default="",
update=project_specific.store,
)
flamenco_job_file_path: StringProperty(
name="Job Storage Path",
description="Path where to store job files, should be accesible for Workers too",
subtype="DIR_PATH",
default=tempfile.gettempdir(),
update=project_specific.store,
)
flamenco_job_output_path: StringProperty(
name="Job Output Path",
description="Path where to store output files, should be accessible for Workers",
subtype="DIR_PATH",
default=tempfile.gettempdir(),
update=project_specific.store,
)
flamenco_job_output_strip_components: IntProperty(
name="Job Output Path Strip Components",
description="The final output path comprises of the job output path, and the blend file "
"path relative to the project with this many path components stripped off "
"the front",
min=0,
default=0,
soft_max=4,
update=project_specific.store,
)
flamenco_relative_only: BoolProperty(
name="Relative Paths Only",
description="When enabled, only assets that are referred to with a relative path are "
"packed, and assets referred to by an absolute path are excluded from the "
"BAT pack. When disabled, all assets are packed",
default=False,
update=project_specific.store,
)
flamenco_open_browser_after_submit: BoolProperty(
name="Open Browser after Submitting Job",
description="When enabled, Blender will open a webbrowser",
default=True,
)
flamenco_show_quit_after_submit_button: BoolProperty(
name='Show "Submit & Quit" button',
description='When enabled, next to the "Render on Flamenco" button there will be a button '
'"Submit & Quit" that silently quits Blender after submitting the render job '
"to Flamenco",
default=False,
)
local_texture_dir = StringProperty(
name='Default Blender Cloud texture storage directory',
subtype='DIR_PATH',
default='//textures')
def draw(self, context):
import textwrap
@ -289,244 +56,54 @@ class BlenderCloudPreferences(AddonPreferences):
blender_id_profile = None
else:
blender_id_profile = blender_id.get_active_profile()
if blender_id is None:
msg_icon = "ERROR"
text = "This add-on requires Blender ID"
help_text = (
"Make sure that the Blender ID add-on is installed and activated"
)
icon = 'ERROR'
text = 'This add-on requires Blender ID'
help_text = 'Make sure that the Blender ID add-on is installed and activated'
elif not blender_id_profile:
msg_icon = "ERROR"
text = "You are logged out."
help_text = "To login, go to the Blender ID add-on preferences."
elif bpy.app.debug and pillar.SUBCLIENT_ID not in blender_id_profile.subclients:
msg_icon = "QUESTION"
text = "No Blender Cloud credentials."
help_text = (
"You are logged in on Blender ID, but your credentials have not "
"been synchronized with Blender Cloud yet. Press the Update "
"Credentials button."
)
icon = 'ERROR'
text = 'You are logged out.'
help_text = 'To login, go to the Blender ID add-on preferences.'
elif pillar.SUBCLIENT_ID not in blender_id_profile.subclients:
icon = 'QUESTION'
text = 'No Blender Cloud credentials.'
help_text = ('You are logged in on Blender ID, but your credentials have not '
'been synchronized with Blender Cloud yet. Press the Update '
'Credentials button.')
else:
msg_icon = "WORLD_DATA"
text = "You are logged in as %s." % blender_id_profile.username
help_text = (
"To logout or change profile, "
"go to the Blender ID add-on preferences."
)
icon = 'WORLD_DATA'
text = 'You are logged in as %s.' % blender_id_profile.username
help_text = ('To logout or change profile, '
'go to the Blender ID add-on preferences.')
# Authentication stuff
auth_box = layout.box()
auth_box.label(text=text, icon=msg_icon)
sub = layout.column(align=True)
sub.label(text=text, icon=icon)
help_lines = textwrap.wrap(help_text, 80)
for line in help_lines:
auth_box.label(text=line)
if bpy.app.debug:
auth_box.operator("pillar.credentials_update")
sub.label(text=line)
# Texture browser stuff
texture_box = layout.box()
texture_box.enabled = msg_icon != "ERROR"
sub = texture_box.column()
sub.label(
text="Local directory for downloaded textures", icon_value=icon("CLOUD")
)
sub.prop(self, "local_texture_dir", text="Default")
sub.prop(context.scene, "local_texture_dir", text="Current scene")
sub = layout.column()
sub.label(text='Local directory for downloaded textures')
sub.prop(self, "local_texture_dir", text='Default')
sub.prop(context.scene, "local_texture_dir", text='Current scene')
# Blender Sync stuff
bss = context.window_manager.blender_sync_status
bsync_box = layout.box()
bsync_box.enabled = msg_icon != "ERROR"
row = bsync_box.row().split(factor=0.33)
row.label(text="Blender Sync with Blender Cloud", icon_value=icon("CLOUD"))
# options for Pillar
sub = layout.column()
sub.enabled = icon != 'ERROR'
icon_for_level = {
"INFO": "NONE",
"WARNING": "INFO",
"ERROR": "ERROR",
"SUBSCRIBE": "ERROR",
}
msg_icon = icon_for_level[bss.level] if bss.message else "NONE"
message_container = row.row()
message_container.label(text=bss.message, icon=msg_icon)
sub = bsync_box.column()
if bss.level == "SUBSCRIBE":
self.draw_subscribe_button(sub)
self.draw_sync_buttons(sub, bss)
# Image Share stuff
share_box = layout.box()
share_box.label(text="Image Sharing on Blender Cloud", icon_value=icon("CLOUD"))
share_box.prop(self, "open_browser_after_share")
# Project selector
project_box = layout.box()
project_box.enabled = self.project.status in {"NONE", "IDLE"}
self.draw_project_selector(project_box, self.project)
extensions = project_extensions(self.project.project)
# Flamenco stuff
if "flamenco" in extensions:
flamenco_box = project_box.column()
self.draw_flamenco_buttons(flamenco_box, self.flamenco_manager, context)
def draw_subscribe_button(self, layout):
layout.operator("pillar.subscribe", icon="WORLD")
def draw_sync_buttons(self, layout, bss):
layout.enabled = bss.status in {"NONE", "IDLE"}
buttons = layout.column()
row_buttons = buttons.row().split(factor=0.5)
row_push = row_buttons.row()
row_pull = row_buttons.row(align=True)
row_push.operator(
"pillar.sync",
text="Save %i.%i settings" % bpy.app.version[:2],
icon="TRIA_UP",
).action = "PUSH"
versions = bss.available_blender_versions
if bss.status in {"NONE", "IDLE"}:
if not versions:
row_pull.operator(
"pillar.sync", text="Find version to load", icon="TRIA_DOWN"
).action = "REFRESH"
else:
props = row_pull.operator(
"pillar.sync",
text="Load %s settings" % bss.version,
icon="TRIA_DOWN",
)
props.action = "PULL"
props.blender_version = bss.version
row_pull.operator(
"pillar.sync", text="", icon="DOWNARROW_HLT"
).action = "SELECT"
else:
row_pull.label(text="Cloud Sync is running.")
def draw_project_selector(self, project_box, bcp: BlenderCloudProjectGroup):
project_row = project_box.row(align=True)
project_row.label(text="Project settings", icon_value=icon("CLOUD"))
row_buttons = project_row.row(align=True)
projects = bcp.available_projects
project = bcp.project
if bcp.status in {"NONE", "IDLE"}:
if not projects:
row_buttons.operator(
"pillar.projects", text="Find project to load", icon="FILE_REFRESH"
)
else:
row_buttons.prop(bcp, "project")
row_buttons.operator("pillar.projects", text="", icon="FILE_REFRESH")
props = row_buttons.operator(
"pillar.project_open_in_browser", text="", icon="WORLD"
)
props.project_id = project
else:
row_buttons.label(text="Fetching available projects.")
enabled_for = project_extensions(project)
if not project:
return
if not enabled_for:
project_box.label(text="This project is not set up for Attract or Flamenco")
return
project_box.label(
text="This project is set up for: %s" % ", ".join(sorted(enabled_for))
)
# This is only needed when the project is set up for either Attract or Flamenco.
project_box.prop(self, "cloud_project_local_path", text="Local Project Path")
def draw_flamenco_buttons(
self, flamenco_box, bcp: flamenco.FlamencoManagerGroup, context
):
header_row = flamenco_box.row(align=True)
header_row.label(text="Flamenco:", icon_value=icon("CLOUD"))
manager_split = flamenco_box.split(factor=0.32, align=True)
manager_split.label(text="Manager:")
manager_box = manager_split.row(align=True)
if bcp.status in {"NONE", "IDLE"}:
if not bcp.available_managers:
manager_box.operator(
"flamenco.managers",
text="Find Flamenco Managers",
icon="FILE_REFRESH",
)
else:
manager_box.prop(bcp, "manager", text="")
manager_box.operator("flamenco.managers", text="", icon="FILE_REFRESH")
else:
manager_box.label(text="Fetching available managers.")
path_split = flamenco_box.split(factor=0.32, align=True)
path_split.label(text="Job File Path:")
path_box = path_split.row(align=True)
path_box.prop(self, "flamenco_job_file_path", text="")
props = path_box.operator(
"flamenco.explore_file_path", text="", icon="DISK_DRIVE"
)
props.path = self.flamenco_job_file_path
job_output_box = flamenco_box.column(align=True)
path_split = job_output_box.split(factor=0.32, align=True)
path_split.label(text="Job Output Path:")
path_box = path_split.row(align=True)
path_box.prop(self, "flamenco_job_output_path", text="")
props = path_box.operator(
"flamenco.explore_file_path", text="", icon="DISK_DRIVE"
)
props.path = self.flamenco_job_output_path
job_output_box.prop(self, "flamenco_exclude_filter")
prop_split = job_output_box.split(factor=0.32, align=True)
prop_split.label(text="Strip Components:")
prop_split.prop(self, "flamenco_job_output_strip_components", text="")
from .flamenco import render_output_path
path_box = job_output_box.row(align=True)
output_path = render_output_path(context)
if output_path:
path_box.label(text=str(output_path))
props = path_box.operator(
"flamenco.explore_file_path", text="", icon="DISK_DRIVE"
)
props.path = str(output_path.parent)
else:
path_box.label(
text="Blend file is not in your project path, "
"unable to give output path example."
)
flamenco_box.prop(self, "flamenco_relative_only")
flamenco_box.prop(self, "flamenco_open_browser_after_submit")
flamenco_box.prop(self, "flamenco_show_quit_after_submit_button")
# TODO: let users easily pick a project. For now, we just use the
# hard-coded server URL and UUID of the textures project.
# sub.prop(self, "pillar_server")
# sub.prop(self, "project_uuid")
sub.operator("pillar.credentials_update")
class PillarCredentialsUpdate(pillar.PillarOperatorMixin, Operator):
class PillarCredentialsUpdate(Operator):
"""Updates the Pillar URL and tests the new URL."""
bl_idname = "pillar.credentials_update"
bl_label = "Update credentials"
bl_description = "Resynchronises your Blender ID login with Blender Cloud"
log = logging.getLogger("bpy.ops.%s" % bl_idname)
bl_idname = 'pillar.credentials_update'
bl_label = 'Update credentials'
@classmethod
def poll(cls, context):
@ -548,224 +125,43 @@ class PillarCredentialsUpdate(pillar.PillarOperatorMixin, Operator):
# Only allow activation when the user is actually logged in.
if not self.is_logged_in(context):
self.report({"ERROR"}, "No active profile found")
return {"CANCELLED"}
self.report({'ERROR'}, 'No active profile found')
return {'CANCELLED'}
try:
loop = asyncio.get_event_loop()
loop.run_until_complete(self.check_credentials(context, set()))
loop.run_until_complete(pillar.refresh_pillar_credentials())
except blender_id.BlenderIdCommError as ex:
log.exception("Error sending subclient-specific token to Blender ID")
self.report({"ERROR"}, "Failed to sync Blender ID to Blender Cloud")
return {"CANCELLED"}
log.exception('Error sending subclient-specific token to Blender ID')
self.report({'ERROR'}, 'Failed to sync Blender ID to Blender Cloud')
return {'CANCELLED'}
except Exception as ex:
log.exception("Error in test call to Pillar")
self.report({"ERROR"}, "Failed test connection to Blender Cloud")
return {"CANCELLED"}
log.exception('Error in test call to Pillar')
self.report({'ERROR'}, 'Failed test connection to Blender Cloud')
return {'CANCELLED'}
self.report({"INFO"}, "Blender Cloud credentials & endpoint URL updated.")
return {"FINISHED"}
class PILLAR_OT_subscribe(Operator):
"""Opens a browser to subscribe the user to the Cloud."""
bl_idname = "pillar.subscribe"
bl_label = "Subscribe to the Cloud"
bl_description = "Opens a page in a web browser to subscribe to the Blender Cloud"
def execute(self, context):
import webbrowser
webbrowser.open_new_tab("https://cloud.blender.org/join")
self.report({"INFO"}, "We just started a browser for you.")
return {"FINISHED"}
class PILLAR_OT_project_open_in_browser(Operator):
bl_idname = "pillar.project_open_in_browser"
bl_label = "Open in Browser"
bl_description = "Opens a webbrowser to show the project"
project_id: StringProperty(name="Project ID")
def execute(self, context):
if not self.project_id:
return {"CANCELLED"}
import webbrowser
import urllib.parse
import pillarsdk
from .pillar import sync_call
project = sync_call(
pillarsdk.Project.find, self.project_id, {"projection": {"url": True}}
)
if log.isEnabledFor(logging.DEBUG):
import pprint
log.debug("found project: %s", pprint.pformat(project.to_dict()))
url = urllib.parse.urljoin(PILLAR_WEB_SERVER_URL, "p/" + project.url)
webbrowser.open_new_tab(url)
self.report({"INFO"}, "Opened a browser at %s" % url)
return {"FINISHED"}
class PILLAR_OT_projects(
async_loop.AsyncModalOperatorMixin,
pillar.AuthenticatedPillarOperatorMixin,
Operator,
):
"""Fetches the projects available to the user"""
bl_idname = "pillar.projects"
bl_label = "Fetch available projects"
stop_upon_exception = True
_log = logging.getLogger("bpy.ops.%s" % bl_idname)
async def async_execute(self, context):
if not await self.authenticate(context):
return
import pillarsdk
from .pillar import pillar_call
self.log.info("Going to fetch projects for user %s", self.user_id)
preferences().project.status = "FETCHING"
# Get all projects, except the home project.
projects_user = await pillar_call(
pillarsdk.Project.all,
{
"where": {"user": self.user_id, "category": {"$ne": "home"}},
"sort": "-name",
"projection": {"_id": True, "name": True, "extension_props": True},
},
)
projects_shared = await pillar_call(
pillarsdk.Project.all,
{
"where": {
"user": {"$ne": self.user_id},
"permissions.groups.group": {"$in": self.db_user.groups},
},
"sort": "-name",
"projection": {"_id": True, "name": True, "extension_props": True},
},
)
# We need to convert to regular dicts before storing in ID properties.
# Also don't store more properties than we need.
def reduce_properties(project_list):
for p in project_list:
p = p.to_dict()
extension_props = p.get("extension_props", {})
enabled_for = list(extension_props.keys())
self._log.debug("Project %r is enabled for %s", p["name"], enabled_for)
yield {
"_id": p["_id"],
"name": p["name"],
"enabled_for": enabled_for,
}
projects = list(reduce_properties(projects_user["_items"])) + list(
reduce_properties(projects_shared["_items"])
)
def proj_sort_key(project):
return project.get("name")
preferences().project.available_projects = sorted(projects, key=proj_sort_key)
self.quit()
def quit(self):
preferences().project.status = "IDLE"
super().quit()
class PILLAR_PT_image_custom_properties(rna_prop_ui.PropertyPanel, bpy.types.Panel):
"""Shows custom properties in the image editor."""
bl_space_type = "IMAGE_EDITOR"
bl_region_type = "UI"
bl_label = "Custom Properties"
_context_path = "edit_image"
_property_type = bpy.types.Image
def ctx_preferences():
"""Returns bpy.context.preferences in a 2.79-compatible way."""
try:
return bpy.context.preferences
except AttributeError:
return bpy.context.user_preferences
self.report({'INFO'}, 'Blender Cloud credentials & endpoint URL updated.')
return {'FINISHED'}
def preferences() -> BlenderCloudPreferences:
return ctx_preferences().addons[ADDON_NAME].preferences
def load_custom_icons():
global icons
if icons is not None:
# Already loaded
return
import bpy.utils.previews
icons = bpy.utils.previews.new()
my_icons_dir = os.path.join(os.path.dirname(__file__), "icons")
icons.load("CLOUD", os.path.join(my_icons_dir, "icon-cloud.png"), "IMAGE")
def unload_custom_icons():
global icons
if icons is None:
# Already unloaded
return
bpy.utils.previews.remove(icons)
icons = None
def icon(icon_name: str) -> int:
"""Returns the icon ID for the named icon.
Use with layout.operator('pillar.image_share', icon_value=icon('CLOUD'))
"""
return icons[icon_name].icon_id
return bpy.context.user_preferences.addons[ADDON_NAME].preferences
def register():
bpy.utils.register_class(BlenderCloudProjectGroup)
bpy.utils.register_class(BlenderCloudPreferences)
bpy.utils.register_class(PillarCredentialsUpdate)
bpy.utils.register_class(SyncStatusProperties)
bpy.utils.register_class(PILLAR_OT_subscribe)
bpy.utils.register_class(PILLAR_OT_projects)
bpy.utils.register_class(PILLAR_OT_project_open_in_browser)
bpy.utils.register_class(PILLAR_PT_image_custom_properties)
WindowManager.blender_cloud_project = StringProperty(
name="Blender Cloud project UUID",
default='5672beecc0261b2005ed1a33') # TODO: don't hard-code this
WindowManager.blender_cloud_node = StringProperty(
name="Blender Cloud node UUID",
default='') # empty == top-level of project
addon_prefs = preferences()
WindowManager.last_blender_cloud_location = StringProperty(
name="Last Blender Cloud browser location", default="/"
)
def default_if_empty(scene, context):
"""The scene's local_texture_dir, if empty, reverts to the addon prefs."""
@ -773,28 +169,18 @@ def register():
scene.local_texture_dir = addon_prefs.local_texture_dir
Scene.local_texture_dir = StringProperty(
name="Blender Cloud texture storage directory for current scene",
subtype="DIR_PATH",
name='Blender Cloud texture storage directory for current scene',
subtype='DIR_PATH',
default=addon_prefs.local_texture_dir,
update=default_if_empty,
)
WindowManager.blender_sync_status = PointerProperty(type=SyncStatusProperties)
load_custom_icons()
update=default_if_empty)
def unregister():
unload_custom_icons()
gui.unregister()
bpy.utils.unregister_class(BlenderCloudProjectGroup)
bpy.utils.unregister_class(PillarCredentialsUpdate)
bpy.utils.unregister_class(BlenderCloudPreferences)
bpy.utils.unregister_class(SyncStatusProperties)
bpy.utils.unregister_class(PILLAR_OT_subscribe)
bpy.utils.unregister_class(PILLAR_OT_projects)
bpy.utils.unregister_class(PILLAR_OT_project_open_in_browser)
bpy.utils.unregister_class(PILLAR_PT_image_custom_properties)
del WindowManager.last_blender_cloud_location
del WindowManager.blender_sync_status
del WindowManager.blender_cloud_project
del WindowManager.blender_cloud_node
del WindowManager.blender_cloud_thumbnails

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,3 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
"""HTTP Cache management.
This module configures a cached session for the Requests package.
@ -33,9 +15,7 @@ from cachecontrol.caches import FileCache
from . import appdirs
log = logging.getLogger(__name__)
_session = (
None # requests.Session object that's set up for caching by requests_session().
)
_session = None # requests.Session object that's set up for caching by requests_session().
def cache_directory(*subdirs) -> str:
@ -58,12 +38,12 @@ def cache_directory(*subdirs) -> str:
if profile:
username = profile.username
else:
username = "anonymous"
username = 'anonymous'
# TODO: use bpy.utils.user_resource('CACHE', ...)
# once https://developer.blender.org/T47684 is finished.
user_cache_dir = appdirs.user_cache_dir(appname="Blender", appauthor=False)
cache_dir = os.path.join(user_cache_dir, "blender_cloud", username, *subdirs)
user_cache_dir = appdirs.user_cache_dir(appname='Blender', appauthor=False)
cache_dir = os.path.join(user_cache_dir, 'blender_cloud', username, *subdirs)
os.makedirs(cache_dir, mode=0o700, exist_ok=True)
@ -78,11 +58,10 @@ def requests_session() -> requests.Session:
if _session is not None:
return _session
cache_name = cache_directory("blender_cloud_http")
log.info("Storing cache in %s" % cache_name)
cache_name = cache_directory('blender_cloud_http')
log.info('Storing cache in %s' % cache_name)
_session = cachecontrol.CacheControl(
sess=requests.session(), cache=FileCache(cache_name)
)
_session = cachecontrol.CacheControl(sess=requests.session(),
cache=FileCache(cache_name))
return _session

File diff suppressed because it is too large Load Diff

View File

@ -1,199 +0,0 @@
"""BAT🦇 packing interface for Flamenco."""
import asyncio
import logging
import pathlib
import re
import threading
import typing
import urllib.parse
import bpy
from blender_asset_tracer import pack
from blender_asset_tracer.pack import progress, transfer, shaman
log = logging.getLogger(__name__)
_running_packer = None # type: pack.Packer
_packer_lock = threading.RLock()
# For using in other parts of the add-on, so only this file imports BAT.
Aborted = pack.Aborted
FileTransferError = transfer.FileTransferError
parse_shaman_endpoint = shaman.parse_endpoint
class BatProgress(progress.Callback):
"""Report progress of BAT Packing to the UI.
Uses asyncio.run_coroutine_threadsafe() to ensure the UI is only updated
from the main thread. This is required since we run the BAT Pack in a
background thread.
"""
def __init__(self) -> None:
super().__init__()
self.loop = asyncio.get_event_loop()
def _set_attr(self, attr: str, value):
async def do_it():
setattr(bpy.context.window_manager, attr, value)
asyncio.run_coroutine_threadsafe(do_it(), loop=self.loop)
def _txt(self, msg: str):
"""Set a text in a thread-safe way."""
self._set_attr("flamenco_status_txt", msg)
def _status(self, status: str):
"""Set the flamenco_status property in a thread-safe way."""
self._set_attr("flamenco_status", status)
def _progress(self, progress: int):
"""Set the flamenco_progress property in a thread-safe way."""
self._set_attr("flamenco_progress", progress)
def pack_start(self) -> None:
self._txt("Starting BAT Pack operation")
def pack_done(
self, output_blendfile: pathlib.Path, missing_files: typing.Set[pathlib.Path]
) -> None:
if missing_files:
self._txt("There were %d missing files" % len(missing_files))
else:
self._txt("Pack of %s done" % output_blendfile.name)
def pack_aborted(self, reason: str):
self._txt("Aborted: %s" % reason)
self._status("ABORTED")
def trace_blendfile(self, filename: pathlib.Path) -> None:
"""Called for every blendfile opened when tracing dependencies."""
self._txt("Inspecting %s" % filename.name)
def trace_asset(self, filename: pathlib.Path) -> None:
if filename.stem == ".blend":
return
self._txt("Found asset %s" % filename.name)
def rewrite_blendfile(self, orig_filename: pathlib.Path) -> None:
self._txt("Rewriting %s" % orig_filename.name)
def transfer_file(self, src: pathlib.Path, dst: pathlib.Path) -> None:
self._txt("Transferring %s" % src.name)
def transfer_file_skipped(self, src: pathlib.Path, dst: pathlib.Path) -> None:
self._txt("Skipped %s" % src.name)
def transfer_progress(self, total_bytes: int, transferred_bytes: int) -> None:
self._progress(round(100 * transferred_bytes / total_bytes))
def missing_file(self, filename: pathlib.Path) -> None:
# TODO(Sybren): report missing files in a nice way
pass
class ShamanPacker(shaman.ShamanPacker):
"""Packer with support for getting an auth token from Flamenco Server."""
def __init__(
self,
bfile: pathlib.Path,
project: pathlib.Path,
target: str,
endpoint: str,
checkout_id: str,
*,
manager_id: str,
**kwargs
) -> None:
self.manager_id = manager_id
super().__init__(bfile, project, target, endpoint, checkout_id, **kwargs)
def _get_auth_token(self) -> str:
"""get a token from Flamenco Server"""
from ..blender import PILLAR_SERVER_URL
from ..pillar import blender_id_subclient, uncached_session, SUBCLIENT_ID
url = urllib.parse.urljoin(
PILLAR_SERVER_URL, "flamenco/jwt/generate-token/%s" % self.manager_id
)
auth_token = blender_id_subclient()["token"]
resp = uncached_session.get(url, auth=(auth_token, SUBCLIENT_ID))
resp.raise_for_status()
return resp.text
async def copy(
context,
base_blendfile: pathlib.Path,
project: pathlib.Path,
target: str,
exclusion_filter: str,
*,
relative_only: bool,
packer_class=pack.Packer,
**packer_args
) -> typing.Tuple[pathlib.Path, typing.Set[pathlib.Path]]:
"""Use BAT🦇 to copy the given file and dependencies to the target location.
:raises: FileTransferError if a file couldn't be transferred.
:returns: the path of the packed blend file, and a set of missing sources.
"""
global _running_packer
loop = asyncio.get_event_loop()
wm = bpy.context.window_manager
packer = packer_class(
base_blendfile,
project,
target,
compress=True,
relative_only=relative_only,
**packer_args
)
with packer:
with _packer_lock:
if exclusion_filter:
# There was a mistake in an older version of the property tooltip,
# showing semicolon-separated instead of space-separated. We now
# just handle both.
filter_parts = re.split("[ ;]+", exclusion_filter.strip(" ;"))
packer.exclude(*filter_parts)
packer.progress_cb = BatProgress()
_running_packer = packer
log.debug("awaiting strategise")
wm.flamenco_status = "INVESTIGATING"
await loop.run_in_executor(None, packer.strategise)
log.debug("awaiting execute")
wm.flamenco_status = "TRANSFERRING"
await loop.run_in_executor(None, packer.execute)
log.debug("done")
wm.flamenco_status = "DONE"
with _packer_lock:
_running_packer = None
return packer.output_path, packer.missing_files
def abort() -> None:
"""Abort a running copy() call.
No-op when there is no running copy(). Can be called from any thread.
"""
with _packer_lock:
if _running_packer is None:
log.debug("No running packer, ignoring call to bat_abort()")
return
log.info("Aborting running packer")
_running_packer.abort()

View File

@ -1,116 +0,0 @@
import functools
import pathlib
import typing
from pillarsdk.resource import List, Find, Create
class Manager(List, Find):
"""Manager class wrapping the REST nodes endpoint"""
path = "flamenco/managers"
PurePlatformPath = pathlib.PurePath
@functools.lru_cache(maxsize=1)
def _path_replacements(self) -> list:
"""Defer to _path_replacements_vN() to get path replacement vars.
Returns a list of tuples (variable name, variable value).
"""
settings_version = self.settings_version or 1
try:
settings_func = getattr(self, "_path_replacements_v%d" % settings_version)
except AttributeError:
raise RuntimeError(
"This manager has unsupported settings version %d; "
"upgrade Blender Cloud add-on"
)
def longest_value_first(item):
var_name, var_value = item
return -len(var_value), var_value, var_name
replacements = settings_func()
replacements.sort(key=longest_value_first)
return replacements
def _path_replacements_v1(self) -> typing.List[typing.Tuple[str, str]]:
import platform
if self.path_replacement is None:
return []
items = self.path_replacement.to_dict().items()
this_platform = platform.system().lower()
return [
(varname, platform_replacements[this_platform])
for varname, platform_replacements in items
if this_platform in platform_replacements
]
def _path_replacements_v2(self) -> typing.List[typing.Tuple[str, str]]:
import platform
if not self.variables:
return []
this_platform = platform.system().lower()
audiences = {"users", "all"}
replacements = []
for var_name, variable in self.variables.to_dict().items():
# Path replacement requires bidirectional variables.
if variable.get("direction") != "twoway":
continue
for var_value in variable.get("values", []):
if var_value.get("audience") not in audiences:
continue
if var_value.get("platform", "").lower() != this_platform:
continue
replacements.append((var_name, var_value.get("value")))
return replacements
def replace_path(self, some_path: pathlib.PurePath) -> str:
"""Performs path variable replacement.
Tries to find platform-specific path prefixes, and replaces them with
variables.
"""
assert isinstance(some_path, pathlib.PurePath), (
"some_path should be a PurePath, not %r" % some_path
)
for varname, path in self._path_replacements():
replacement = self.PurePlatformPath(path)
try:
relpath = some_path.relative_to(replacement)
except ValueError:
# Not relative to each other, so no replacement possible
continue
replacement_root = self.PurePlatformPath("{%s}" % varname)
return (replacement_root / relpath).as_posix()
return some_path.as_posix()
class Job(List, Find, Create):
"""Job class wrapping the REST nodes endpoint"""
path = "flamenco/jobs"
ensure_query_projections = {"project": 1}
def patch(self, payload: dict, api=None):
import pillarsdk.utils
api = api or self.api
url = pillarsdk.utils.join_url(self.path, str(self["_id"]))
headers = pillarsdk.utils.merge_dict(
self.http_headers(), {"Content-Type": "application/json"}
)
response = api.patch(url, payload, headers=headers)
return response

770
blender_cloud/gui.py Normal file
View File

@ -0,0 +1,770 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# Copyright (C) 2014 Blender Aid
# http://www.blendearaid.com
# blenderaid@gmail.com
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# ##### END GPL LICENSE BLOCK #####
import asyncio
import logging
import threading
import bpy
import bgl
import blf
import os
from bpy.types import AddonPreferences
from bpy.props import (BoolProperty, EnumProperty,
FloatProperty, FloatVectorProperty,
IntProperty, StringProperty)
import pillarsdk
from . import async_loop, pillar, cache
icon_width = 128
icon_height = 128
target_item_width = 400
target_item_height = 128
library_path = '/tmp'
library_icons_path = os.path.join(os.path.dirname(__file__), "icons")
class UpNode(pillarsdk.Node):
def __init__(self):
super().__init__()
self['_id'] = 'UP'
self['node_type'] = 'UP'
class MenuItem:
"""GUI menu item for the 3D View GUI."""
icon_margin_x = 4
icon_margin_y = 4
text_margin_x = 6
text_height = 16
text_width = 72
DEFAULT_ICONS = {
'FOLDER': os.path.join(library_icons_path, 'folder.png'),
'SPINNER': os.path.join(library_icons_path, 'spinner.png'),
}
SUPPORTED_NODE_TYPES = {'UP', 'group_texture', 'texture'}
def __init__(self, node, file_desc, thumb_path: str, label_text):
if node['node_type'] not in self.SUPPORTED_NODE_TYPES:
raise TypeError('Node of type %r not supported; supported are %r.' % (
node.group_texture, self.SUPPORTED_NODE_TYPES))
self.node = node # pillarsdk.Node, contains 'node_type' key to indicate type
self.file_desc = file_desc # pillarsdk.File object, or None if a 'folder' node.
self.label_text = label_text
self._thumb_path = ''
self.icon = None
self._is_folder = node['node_type'] == 'group_texture' or isinstance(node, UpNode)
self.thumb_path = thumb_path
# Updated when drawing the image
self.x = 0
self.y = 0
self.width = 0
self.height = 0
@property
def thumb_path(self) -> str:
return self._thumb_path
@thumb_path.setter
def thumb_path(self, new_thumb_path: str):
self._thumb_path = self.DEFAULT_ICONS.get(new_thumb_path, new_thumb_path)
if self._thumb_path:
self.icon = bpy.data.images.load(filepath=self._thumb_path)
else:
self.icon = None
@property
def node_uuid(self) -> str:
return self.node['_id']
def update(self, node, file_desc, thumb_path: str, label_text):
# We can get updated information about our Node, but a MenuItem should
# always represent one node, and it shouldn't be shared between nodes.
if self.node_uuid != node['_id']:
raise ValueError("Don't change the node ID this MenuItem reflects, "
"just create a new one.")
self.node = node
self.file_desc = file_desc # pillarsdk.File object, or None if a 'folder' node.
self.thumb_path = thumb_path
self.label_text = label_text
@property
def is_folder(self) -> bool:
return self._is_folder
def update_placement(self, x, y, width, height):
"""Use OpenGL to draw this one menu item."""
self.x = x
self.y = y
self.width = width
self.height = height
def draw(self, highlighted: bool):
bgl.glEnable(bgl.GL_BLEND)
if highlighted:
bgl.glColor4f(0.555, 0.555, 0.555, 0.8)
else:
bgl.glColor4f(0.447, 0.447, 0.447, 0.8)
bgl.glRectf(self.x, self.y, self.x + self.width, self.y + self.height)
texture = self.icon
err = texture.gl_load(filter=bgl.GL_NEAREST, mag=bgl.GL_NEAREST)
assert not err, 'OpenGL error: %i' % err
bgl.glColor4f(0.0, 0.0, 1.0, 0.5)
# bgl.glLineWidth(1.5)
# ------ TEXTURE ---------#
bgl.glBindTexture(bgl.GL_TEXTURE_2D, texture.bindcode[0])
bgl.glEnable(bgl.GL_TEXTURE_2D)
bgl.glBlendFunc(bgl.GL_SRC_ALPHA, bgl.GL_ONE_MINUS_SRC_ALPHA)
bgl.glColor4f(1, 1, 1, 1)
bgl.glBegin(bgl.GL_QUADS)
bgl.glTexCoord2d(0, 0)
bgl.glVertex2d(self.x + self.icon_margin_x, self.y)
bgl.glTexCoord2d(0, 1)
bgl.glVertex2d(self.x + self.icon_margin_x, self.y + icon_height)
bgl.glTexCoord2d(1, 1)
bgl.glVertex2d(self.x + self.icon_margin_x + icon_width, self.y + icon_height)
bgl.glTexCoord2d(1, 0)
bgl.glVertex2d(self.x + self.icon_margin_x + icon_width, self.y)
bgl.glEnd()
bgl.glDisable(bgl.GL_TEXTURE_2D)
bgl.glDisable(bgl.GL_BLEND)
texture.gl_free()
# draw some text
font_id = 0
blf.position(font_id,
self.x + self.icon_margin_x + icon_width + self.text_margin_x,
self.y + icon_height * 0.5 - 0.25 * self.text_height, 0)
blf.size(font_id, self.text_height, self.text_width)
blf.draw(font_id, self.label_text)
def hits(self, mouse_x: int, mouse_y: int) -> bool:
return self.x < mouse_x < self.x + self.width and self.y < mouse_y < self.y + self.height
class BlenderCloudBrowser(bpy.types.Operator):
bl_idname = 'pillar.browser'
bl_label = 'Blender Cloud Texture Browser'
_draw_handle = None
_state = 'INITIALIZING'
project_uuid = '5672beecc0261b2005ed1a33' # Blender Cloud project UUID
node = None # The Node object we're currently showing, or None if we're at the project top.
node_uuid = '' # Blender Cloud node UUID we're currently showing, i.e. None-safe self.node['_id']
# This contains a stack of Node objects that lead up to the currently browsed node.
# This allows us to display the "up" item.
path_stack = []
async_task = None # asyncio task for fetching thumbnails
signalling_future = None # asyncio future for signalling that we want to cancel everything.
timer = None
log = logging.getLogger('%s.BlenderCloudBrowser' % __name__)
_menu_item_lock = threading.Lock()
current_path = ''
current_display_content = []
loaded_images = set()
thumbnails_cache = ''
maximized_area = False
mouse_x = 0
mouse_y = 0
def invoke(self, context, event):
# Refuse to start if the file hasn't been saved.
if not context.blend_data.is_saved:
self.report({'ERROR'}, 'Please save your Blend file before using '
'the Blender Cloud addon.')
return {'CANCELLED'}
wm = context.window_manager
self.project_uuid = wm.blender_cloud_project
self.node_uuid = wm.blender_cloud_node
self.path_stack = []
self.thumbnails_cache = cache.cache_directory('thumbnails')
self.mouse_x = event.mouse_x
self.mouse_y = event.mouse_y
# See if we have to maximize the current area
if not context.screen.show_fullscreen:
self.maximized_area = True
bpy.ops.screen.screen_full_area(use_hide_panels=True)
# Add the region OpenGL drawing callback
# draw in view space with 'POST_VIEW' and 'PRE_VIEW'
self._draw_handle = context.space_data.draw_handler_add(
self.draw_menu, (context,), 'WINDOW', 'POST_PIXEL')
self.current_display_content = []
self.loaded_images = set()
self.check_credentials()
context.window.cursor_modal_set('DEFAULT')
context.window_manager.modal_handler_add(self)
self.timer = context.window_manager.event_timer_add(1 / 30, context.window)
return {'RUNNING_MODAL'}
def modal(self, context, event):
task = self.async_task
if self._state != 'EXCEPTION' and task.done() and not task.cancelled():
ex = task.exception()
if ex is not None:
self._state = 'EXCEPTION'
self.log.error('Exception while running task: %s', ex)
return {'RUNNING_MODAL'}
if self._state == 'QUIT':
self._finish(context)
return {'FINISHED'}
if event.type == 'TAB' and event.value == 'RELEASE':
self.log.info('Ensuring async loop is running')
async_loop.ensure_async_loop()
if event.type == 'TIMER':
context.area.tag_redraw()
return {'RUNNING_MODAL'}
if 'MOUSE' in event.type:
context.area.tag_redraw()
self.mouse_x = event.mouse_x
self.mouse_y = event.mouse_y
left_mouse_release = event.type == 'LEFTMOUSE' and event.value == 'RELEASE'
if self._state == 'PLEASE_SUBSCRIBE' and left_mouse_release:
self.open_browser_subscribe()
self._finish(context)
return {'FINISHED'}
if self._state == 'BROWSING':
selected = self.get_clicked()
if selected:
context.window.cursor_set('HAND')
else:
context.window.cursor_set('DEFAULT')
if left_mouse_release:
if selected is None:
# No item clicked, ignore it.
return {'RUNNING_MODAL'}
if selected.is_folder:
self.descend_node(selected.node)
else:
if selected.file_desc is None:
# This can happen when the thumbnail information isn't loaded yet.
# Just ignore the click for now.
# TODO: think of a way to handle this properly.
return {'RUNNING_MODAL'}
self.handle_item_selection(context, selected)
if event.type in {'RIGHTMOUSE', 'ESC'}:
self._finish(context)
return {'CANCELLED'}
return {'RUNNING_MODAL'}
def check_credentials(self):
self._state = 'CHECKING_CREDENTIALS'
self.log.debug('Checking credentials')
self._new_async_task(self._check_credentials())
async def _check_credentials(self):
"""Checks credentials with Pillar, and if ok goes to the BROWSING state."""
try:
await pillar.check_pillar_credentials()
except pillar.NotSubscribedToCloudError:
self.log.info('User not subscribed to Blender Cloud.')
self._show_subscribe_screen()
return
except pillar.CredentialsNotSyncedError:
self.log.info('Credentials not synced, re-syncing automatically.')
else:
self.log.info('Credentials okay, browsing assets.')
await self.async_download_previews()
return
try:
await pillar.refresh_pillar_credentials()
except pillar.NotSubscribedToCloudError:
self.log.info('User is not a Blender Cloud subscriber.')
self._show_subscribe_screen()
return
except pillar.UserNotLoggedInError:
self.error('User not logged in on Blender ID.')
else:
self.log.info('Credentials refreshed and ok, browsing assets.')
await self.async_download_previews()
return
raise pillar.UserNotLoggedInError()
# self._new_async_task(self._check_credentials())
def _show_subscribe_screen(self):
"""Shows the "You need to subscribe" screen."""
self._state = 'PLEASE_SUBSCRIBE'
bpy.context.window.cursor_set('HAND')
def descend_node(self, node):
"""Descends the node hierarchy by visiting this node.
Also keeps track of the current node, so that we know where the "up" button should go.
"""
# Going up or down?
if self.path_stack and isinstance(node, UpNode):
self.log.debug('Going up, pop the stack; pre-pop stack is %r', self.path_stack)
node = self.path_stack.pop()
else:
# Going down, keep track of where we were (project top-level is None)
self.path_stack.append(self.node)
self.log.debug('Going up, push the stack; post-push stack is %r', self.path_stack)
# Set 'current' to the given node
self.node_uuid = node['_id'] if node else None
self.node = node
self.browse_assets()
def _stop_async_task(self):
self.log.debug('Stopping async task')
if self.async_task is None:
self.log.debug('No async task, trivially stopped')
return
# Signal that we want to stop.
self.async_task.cancel()
if not self.signalling_future.done():
self.log.info("Signalling that we want to cancel anything that's running.")
self.signalling_future.cancel()
# Wait until the asynchronous task is done.
if not self.async_task.done():
self.log.info("blocking until async task is done.")
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(self.async_task)
except asyncio.CancelledError:
self.log.info('Asynchronous task was cancelled')
return
# noinspection PyBroadException
try:
self.async_task.result() # This re-raises any exception of the task.
except asyncio.CancelledError:
self.log.info('Asynchronous task was cancelled')
except Exception:
self.log.exception("Exception from asynchronous task")
def _finish(self, context):
self.log.debug('Finishing the modal operator')
self._stop_async_task()
self.clear_images()
context.space_data.draw_handler_remove(self._draw_handle, 'WINDOW')
context.window_manager.event_timer_remove(self.timer)
context.window.cursor_modal_restore()
if self.maximized_area:
bpy.ops.screen.screen_full_area(use_hide_panels=True)
context.area.tag_redraw()
self.log.debug('Modal operator finished')
def clear_images(self):
"""Removes all images we loaded from Blender's memory."""
for image in bpy.data.images:
if image.filepath_raw not in self.loaded_images:
continue
image.user_clear()
bpy.data.images.remove(image)
self.loaded_images.clear()
self.current_display_content.clear()
def add_menu_item(self, *args) -> MenuItem:
menu_item = MenuItem(*args)
# Just make this thread-safe to be on the safe side.
with self._menu_item_lock:
self.current_display_content.append(menu_item)
self.loaded_images.add(menu_item.icon.filepath_raw)
return menu_item
def update_menu_item(self, node, *args) -> MenuItem:
node_uuid = node['_id']
# Just make this thread-safe to be on the safe side.
with self._menu_item_lock:
for menu_item in self.current_display_content:
if menu_item.node_uuid == node_uuid:
menu_item.update(node, *args)
self.loaded_images.add(menu_item.icon.filepath_raw)
break
else:
raise ValueError('Unable to find MenuItem(node_uuid=%r)' % node_uuid)
async def async_download_previews(self):
self._state = 'BROWSING'
thumbnails_directory = self.thumbnails_cache
self.log.info('Asynchronously downloading previews to %r', thumbnails_directory)
self.clear_images()
def thumbnail_loading(node, texture_node):
self.add_menu_item(node, None, 'SPINNER', texture_node['name'])
def thumbnail_loaded(node, file_desc, thumb_path):
self.update_menu_item(node, file_desc, thumb_path, file_desc['filename'])
# Download either by group_texture node UUID or by project UUID (which
# shows all top-level nodes)
if self.node_uuid:
self.log.debug('Getting subnodes for parent node %r', self.node_uuid)
children = await pillar.get_nodes(parent_node_uuid=self.node_uuid,
node_type='group_textures')
# Make sure we can go up again.
if self.path_stack:
self.add_menu_item(UpNode(), None, 'FOLDER', '.. up ..')
elif self.project_uuid:
self.log.debug('Getting subnodes for project node %r', self.project_uuid)
children = await pillar.get_nodes(self.project_uuid, '')
else:
# TODO: add "nothing here" icon and trigger re-draw
self.log.warning("Not node UUID and no project UUID, I can't do anything!")
return
# Download all child nodes
self.log.debug('Iterating over child nodes of %r', self.node_uuid)
for child in children:
# print(' - %(_id)s = %(name)s' % child)
self.add_menu_item(child, None, 'FOLDER', child['name'])
# There are only sub-nodes at the project level, no texture nodes,
# so we won't have to bother looking for textures.
if not self.node_uuid:
return
directory = os.path.join(thumbnails_directory, self.project_uuid, self.node_uuid)
os.makedirs(directory, exist_ok=True)
self.log.debug('Fetching texture thumbnails for node %r', self.node_uuid)
await pillar.fetch_texture_thumbs(self.node_uuid, 's', directory,
thumbnail_loading=thumbnail_loading,
thumbnail_loaded=thumbnail_loaded,
future=self.signalling_future)
def browse_assets(self):
self.log.debug('Browsing assets at project %r node %r', self.project_uuid, self.node_uuid)
self._new_async_task(self.async_download_previews())
def _new_async_task(self, async_task: asyncio.coroutine, future: asyncio.Future = None):
"""Stops the currently running async task, and starts another one."""
self.log.debug('Setting up a new task %r, so any existing task must be stopped', async_task)
self._stop_async_task()
# Download the previews asynchronously.
self.signalling_future = future or asyncio.Future()
self.async_task = asyncio.ensure_future(async_task)
self.log.debug('Created new task %r', self.async_task)
# Start the async manager so everything happens.
async_loop.ensure_async_loop()
def draw_menu(self, context):
"""Draws the GUI with OpenGL."""
drawers = {
'CHECKING_CREDENTIALS': self._draw_checking_credentials,
'BROWSING': self._draw_browser,
'DOWNLOADING_TEXTURE': self._draw_downloading,
'EXCEPTION': self._draw_exception,
'PLEASE_SUBSCRIBE': self._draw_subscribe,
}
if self._state in drawers:
drawer = drawers[self._state]
drawer(context)
# For debugging: draw the state
font_id = 0
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
blf.size(font_id, 20, 72)
blf.position(font_id, 5, 5, 0)
blf.draw(font_id, self._state)
bgl.glDisable(bgl.GL_BLEND)
@staticmethod
def _window_region(context):
window_regions = [region
for region in context.area.regions
if region.type == 'WINDOW']
return window_regions[0]
def _draw_browser(self, context):
"""OpenGL drawing code for the BROWSING state."""
margin_x = 5
margin_y = 5
padding_x = 5
window_region = self._window_region(context)
content_width = window_region.width - margin_x * 2
content_height = window_region.height - margin_y * 2
content_x = margin_x
content_y = context.area.height - margin_y - target_item_height
col_count = content_width // target_item_width
item_width = (content_width - (col_count * padding_x)) / col_count
item_height = target_item_height
block_width = item_width + padding_x
block_height = item_height + margin_y
bgl.glEnable(bgl.GL_BLEND)
bgl.glColor4f(0.0, 0.0, 0.0, 0.6)
bgl.glRectf(0, 0, window_region.width, window_region.height)
if self.current_display_content:
for item_idx, item in enumerate(self.current_display_content):
x = content_x + (item_idx % col_count) * block_width
y = content_y - (item_idx // col_count) * block_height
item.update_placement(x, y, item_width, item_height)
item.draw(highlighted=item.hits(self.mouse_x, self.mouse_y))
else:
font_id = 0
text = "Communicating with Blender Cloud"
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
blf.size(font_id, 20, 72)
text_width, text_height = blf.dimensions(font_id, text)
blf.position(font_id,
content_x + content_width * 0.5 - text_width * 0.5,
content_y - content_height * 0.3 + text_height * 0.5, 0)
blf.draw(font_id, text)
bgl.glDisable(bgl.GL_BLEND)
# bgl.glColor4f(0.0, 0.0, 0.0, 1.0)
def _draw_downloading(self, context):
"""OpenGL drawing code for the DOWNLOADING_TEXTURE state."""
self._draw_text_on_colour(context,
'Downloading texture from Blender Cloud',
(0.0, 0.0, 0.2, 0.6))
def _draw_checking_credentials(self, context):
"""OpenGL drawing code for the CHECKING_CREDENTIALS state."""
self._draw_text_on_colour(context,
'Checking login credentials',
(0.0, 0.0, 0.2, 0.6))
def _draw_text_on_colour(self, context, text, bgcolour):
content_height, content_width = self._window_size(context)
bgl.glEnable(bgl.GL_BLEND)
bgl.glColor4f(*bgcolour)
bgl.glRectf(0, 0, content_width, content_height)
font_id = 0
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
blf.size(font_id, 20, 72)
text_width, text_height = blf.dimensions(font_id, text)
blf.position(font_id,
content_width * 0.5 - text_width * 0.5,
content_height * 0.7 + text_height * 0.5, 0)
blf.draw(font_id, text)
bgl.glDisable(bgl.GL_BLEND)
def _window_size(self, context):
window_region = self._window_region(context)
content_width = window_region.width
content_height = window_region.height
return content_height, content_width
def _draw_exception(self, context):
"""OpenGL drawing code for the EXCEPTION state."""
import textwrap
content_height, content_width = self._window_size(context)
bgl.glEnable(bgl.GL_BLEND)
bgl.glColor4f(0.2, 0.0, 0.0, 0.6)
bgl.glRectf(0, 0, content_width, content_height)
font_id = 0
ex = self.async_task.exception()
if isinstance(ex, pillar.UserNotLoggedInError):
ex_msg = 'You are not logged in on Blender ID. Please log in at User Preferences, ' \
'System, Blender ID.'
else:
ex_msg = str(ex)
if not ex_msg:
ex_msg = str(type(ex))
text = "An error occurred:\n%s" % ex_msg
lines = textwrap.wrap(text)
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
blf.size(font_id, 20, 72)
_, text_height = blf.dimensions(font_id, 'yhBp')
def position(line_nr):
blf.position(font_id,
content_width * 0.1,
content_height * 0.8 - line_nr * text_height, 0)
for line_idx, line in enumerate(lines):
position(line_idx)
blf.draw(font_id, line)
bgl.glDisable(bgl.GL_BLEND)
def _draw_subscribe(self, context):
self._draw_text_on_colour(context,
'Click to subscribe to the Blender Cloud',
(0.0, 0.0, 0.2, 0.6))
def get_clicked(self) -> MenuItem:
for item in self.current_display_content:
if item.hits(self.mouse_x, self.mouse_y):
return item
return None
def handle_item_selection(self, context, item: MenuItem):
"""Called when the user clicks on a menu item that doesn't represent a folder."""
self.clear_images()
self._state = 'DOWNLOADING_TEXTURE'
node_path_components = [node['name'] for node in self.path_stack if node is not None]
local_path_components = [self.project_uuid] + node_path_components + [self.node['name']]
top_texture_directory = bpy.path.abspath(context.scene.local_texture_dir)
local_path = os.path.join(top_texture_directory, *local_path_components)
meta_path = os.path.join(top_texture_directory, '.blender_cloud')
self.log.info('Downloading texture %r to %s', item.node_uuid, local_path)
self.log.debug('Metadata will be stored at %s', meta_path)
file_paths = []
def texture_downloading(file_path, file_desc, *args):
self.log.info('Texture downloading to %s', file_path)
def texture_downloaded(file_path, file_desc, *args):
self.log.info('Texture downloaded to %r.', file_path)
image_dblock = bpy.data.images.load(filepath=file_path)
image_dblock['bcloud_file_uuid'] = file_desc['_id']
image_dblock['bcloud_texture_node_uuid'] = item.node_uuid
file_paths.append(file_path)
def texture_download_completed(_):
self.log.info('Texture download complete, inspect:\n%s', '\n'.join(file_paths))
self._state = 'QUIT'
signalling_future = asyncio.Future()
self._new_async_task(pillar.download_texture(item.node, local_path,
metadata_directory=meta_path,
texture_loading=texture_downloading,
texture_loaded=texture_downloaded,
future=signalling_future))
self.async_task.add_done_callback(texture_download_completed)
def open_browser_subscribe(self):
import webbrowser
webbrowser.open_new_tab('https://cloud.blender.org/join')
self.report({'INFO'}, 'We just started a browser for you.')
# store keymaps here to access after registration
addon_keymaps = []
def menu_draw(self, context):
layout = self.layout
layout.separator()
layout.operator(BlenderCloudBrowser.bl_idname, icon='MOD_SCREW')
def register():
bpy.utils.register_class(BlenderCloudBrowser)
# bpy.types.INFO_MT_mesh_add.append(menu_draw)
# handle the keymap
wm = bpy.context.window_manager
kc = wm.keyconfigs.addon
if not kc:
print('No addon key configuration space found, so no custom hotkeys added.')
return
km = kc.keymaps.new(name='Screen')
kmi = km.keymap_items.new('pillar.browser', 'A', 'PRESS', ctrl=True, shift=True, alt=True)
addon_keymaps.append((km, kmi))
def unregister():
bpy.utils.unregister_class(BlenderCloudBrowser)
# handle the keymap
for km, kmi in addon_keymaps:
km.keymap_items.remove(kmi)
addon_keymaps.clear()
if __name__ == "__main__":
register()

View File

@ -1,53 +0,0 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import logging
import pillarsdk
from pillarsdk import exceptions as sdk_exceptions
from .pillar import pillar_call
log = logging.getLogger(__name__)
HOME_PROJECT_ENDPOINT = "/bcloud/home-project"
async def get_home_project(params=None) -> pillarsdk.Project:
"""Returns the home project."""
log.debug("Getting home project")
try:
return await pillar_call(
pillarsdk.Project.find_from_endpoint, HOME_PROJECT_ENDPOINT, params=params
)
except sdk_exceptions.ForbiddenAccess:
log.warning(
"Access to the home project was denied. "
"Double-check that you are logged in with valid BlenderID credentials."
)
raise
except sdk_exceptions.ResourceNotFound:
log.warning("No home project available.")
raise
async def get_home_project_id() -> str:
"""Returns just the ID of the home project."""
home_proj = await get_home_project({"projection": {"_id": 1}})
home_proj_id = home_proj["_id"]
return home_proj_id

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -1,370 +0,0 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import logging
import os.path
import tempfile
import datetime
import bpy
import pillarsdk
from pillarsdk import exceptions as sdk_exceptions
from .pillar import pillar_call
from . import async_loop, pillar, home_project, blender
REQUIRES_ROLES_FOR_IMAGE_SHARING = {"subscriber", "demo"}
IMAGE_SHARING_GROUP_NODE_NAME = "Image sharing"
log = logging.getLogger(__name__)
async def find_image_sharing_group_id(home_project_id, user_id):
# Find the top-level image sharing group node.
try:
share_group, created = await pillar.find_or_create_node(
where={
"project": home_project_id,
"node_type": "group",
"parent": None,
"name": IMAGE_SHARING_GROUP_NODE_NAME,
},
additional_create_props={
"user": user_id,
"properties": {},
},
projection={"_id": 1},
may_create=True,
)
except pillar.PillarError:
log.exception("Pillar error caught")
raise pillar.PillarError("Unable to find image sharing folder on the Cloud")
return share_group["_id"]
class PILLAR_OT_image_share(
pillar.PillarOperatorMixin, async_loop.AsyncModalOperatorMixin, bpy.types.Operator
):
bl_idname = "pillar.image_share"
bl_label = "Share an image/screenshot via Blender Cloud"
bl_description = "Uploads an image for sharing via Blender Cloud"
log = logging.getLogger("bpy.ops.%s" % bl_idname)
home_project_id = None
home_project_url = "home"
share_group_id = None # top-level share group node ID
user_id = None
target: bpy.props.EnumProperty(
items=[
("FILE", "File", "Share an image file"),
("DATABLOCK", "Datablock", "Share an image datablock"),
("SCREENSHOT", "Screenshot", "Share a screenshot"),
],
name="target",
default="SCREENSHOT",
)
name: bpy.props.StringProperty(
name="name", description="File or datablock name to sync"
)
screenshot_show_multiview: bpy.props.BoolProperty(
name="screenshot_show_multiview", description="Enable Multi-View", default=False
)
screenshot_use_multiview: bpy.props.BoolProperty(
name="screenshot_use_multiview", description="Use Multi-View", default=False
)
screenshot_full: bpy.props.BoolProperty(
name="screenshot_full",
description="Full Screen, Capture the whole window (otherwise only capture the active area)",
default=False,
)
def invoke(self, context, event):
# Do a quick test on datablock dirtyness. If it's not packed and dirty,
# the user should save it first.
if self.target == "DATABLOCK":
if not self.name:
self.report({"ERROR"}, "No name given of the datablock to share.")
return {"CANCELLED"}
datablock = bpy.data.images[self.name]
if (
datablock.type == "IMAGE"
and datablock.is_dirty
and not datablock.packed_file
):
self.report({"ERROR"}, "Datablock is dirty, save it first.")
return {"CANCELLED"}
return async_loop.AsyncModalOperatorMixin.invoke(self, context, event)
async def async_execute(self, context):
"""Entry point of the asynchronous operator."""
# We don't want to influence what is included in the screen shot.
if self.target == "SCREENSHOT":
print("Blender Cloud add-on is communicating with Blender Cloud")
else:
self.report({"INFO"}, "Communicating with Blender Cloud")
try:
# Refresh credentials
try:
db_user = await self.check_credentials(
context, REQUIRES_ROLES_FOR_IMAGE_SHARING
)
self.user_id = db_user["_id"]
self.log.debug("Found user ID: %s", self.user_id)
except pillar.NotSubscribedToCloudError as ex:
self._log_subscription_needed(can_renew=ex.can_renew)
self._state = "QUIT"
return
except pillar.UserNotLoggedInError:
self.log.exception("Error checking/refreshing credentials.")
self.report({"ERROR"}, "Please log in on Blender ID first.")
self._state = "QUIT"
return
# Find the home project.
try:
home_proj = await home_project.get_home_project(
{"projection": {"_id": 1, "url": 1}}
)
except sdk_exceptions.ForbiddenAccess:
self.log.exception("Forbidden access to home project.")
self.report({"ERROR"}, "Did not get access to home project.")
self._state = "QUIT"
return
except sdk_exceptions.ResourceNotFound:
self.report({"ERROR"}, "Home project not found.")
self._state = "QUIT"
return
self.home_project_id = home_proj["_id"]
self.home_project_url = home_proj["url"]
try:
gid = await find_image_sharing_group_id(
self.home_project_id, self.user_id
)
self.share_group_id = gid
self.log.debug("Found group node ID: %s", self.share_group_id)
except sdk_exceptions.ForbiddenAccess:
self.log.exception("Unable to find Group ID")
self.report({"ERROR"}, "Unable to find sync folder.")
self._state = "QUIT"
return
await self.share_image(context)
except Exception as ex:
self.log.exception("Unexpected exception caught.")
self.report({"ERROR"}, "Unexpected error %s: %s" % (type(ex), ex))
self._state = "QUIT"
async def share_image(self, context):
"""Sends files to the Pillar server."""
if self.target == "FILE":
self.report(
{"INFO"}, "Uploading %s '%s'" % (self.target.lower(), self.name)
)
node = await self.upload_file(self.name)
elif self.target == "SCREENSHOT":
node = await self.upload_screenshot(context)
else:
self.report(
{"INFO"}, "Uploading %s '%s'" % (self.target.lower(), self.name)
)
node = await self.upload_datablock(context)
self.report({"INFO"}, "Upload complete, creating link to share.")
share_info = await pillar_call(node.share)
url = share_info.get("short_link")
context.window_manager.clipboard = url
self.report({"INFO"}, "The link has been copied to your clipboard: %s" % url)
await self.maybe_open_browser(url)
async def upload_file(self, filename: str, fileobj=None) -> pillarsdk.Node:
"""Uploads a file to the cloud, attached to the image sharing node.
Returns the node.
"""
self.log.info("Uploading file %s", filename)
node = await pillar_call(
pillarsdk.Node.create_asset_from_file,
self.home_project_id,
self.share_group_id,
"image",
filename,
extra_where={"user": self.user_id},
always_create_new_node=True,
fileobj=fileobj,
caching=False,
)
node_id = node["_id"]
self.log.info("Created node %s", node_id)
self.report({"INFO"}, "File succesfully uploaded to the cloud!")
return node
async def maybe_open_browser(self, url):
prefs = blender.preferences()
if not prefs.open_browser_after_share:
return
import webbrowser
self.log.info("Opening browser at %s", url)
webbrowser.open_new_tab(url)
async def upload_datablock(self, context) -> pillarsdk.Node:
"""Saves a datablock to file if necessary, then upload.
Returns the node.
"""
self.log.info("Uploading datablock '%s'" % self.name)
datablock = bpy.data.images[self.name]
if datablock.type == "RENDER_RESULT":
# Construct a sensible name for this render.
filename = "%s-%s-render%s" % (
os.path.splitext(os.path.basename(context.blend_data.filepath))[0],
context.scene.name,
context.scene.render.file_extension,
)
return await self.upload_via_tempdir(datablock, filename)
if datablock.packed_file is not None:
return await self.upload_packed_file(datablock)
if datablock.is_dirty:
# We can handle dirty datablocks like this if we want.
# However, I (Sybren) do NOT think it's a good idea to:
# - Share unsaved data to the cloud; users can assume it's saved
# to disk and close blender, losing their file.
# - Save unsaved data first; this can overwrite a file a user
# didn't want to overwrite.
filename = bpy.path.basename(datablock.filepath)
return await self.upload_via_tempdir(datablock, filename)
filepath = bpy.path.abspath(datablock.filepath)
return await self.upload_file(filepath)
async def upload_via_tempdir(self, datablock, filename_on_cloud) -> pillarsdk.Node:
"""Saves the datablock to file, and uploads it to the cloud.
Saving is done to a temporary directory, which is removed afterwards.
Returns the node.
"""
with tempfile.TemporaryDirectory() as tmpdir:
filepath = os.path.join(tmpdir, filename_on_cloud)
self.log.debug("Saving %s to %s", datablock, filepath)
datablock.save_render(filepath)
return await self.upload_file(filepath)
async def upload_packed_file(self, datablock) -> pillarsdk.Node:
"""Uploads a packed file directly from memory.
Returns the node.
"""
import io
filename = "%s.%s" % (datablock.name, datablock.file_format.lower())
fileobj = io.BytesIO(datablock.packed_file.data)
fileobj.seek(0) # ensure PillarSDK reads the file from the beginning.
self.log.info("Uploading packed file directly from memory to %r.", filename)
return await self.upload_file(filename, fileobj=fileobj)
async def upload_screenshot(self, context) -> pillarsdk.Node:
"""Takes a screenshot, saves it to a temp file, and uploads it."""
self.name = datetime.datetime.now().strftime("Screenshot-%Y-%m-%d-%H%M%S.png")
self.report({"INFO"}, "Uploading %s '%s'" % (self.target.lower(), self.name))
with tempfile.TemporaryDirectory() as tmpdir:
filepath = os.path.join(tmpdir, self.name)
self.log.debug("Saving screenshot to %s", filepath)
bpy.ops.screen.screenshot(
filepath=filepath,
show_multiview=self.screenshot_show_multiview,
use_multiview=self.screenshot_use_multiview,
full=self.screenshot_full,
)
return await self.upload_file(filepath)
def image_editor_menu(self, context):
image = context.space_data.image
box = self.layout.row()
if image and image.has_data:
text = "Share on Blender Cloud"
if image.type == "IMAGE" and image.is_dirty and not image.packed_file:
box.enabled = False
text = "Save image before sharing on Blender Cloud"
props = box.operator(
PILLAR_OT_image_share.bl_idname, text=text, icon_value=blender.icon("CLOUD")
)
props.target = "DATABLOCK"
props.name = image.name
def window_menu(self, context):
props = self.layout.operator(
PILLAR_OT_image_share.bl_idname,
text="Share screenshot via Blender Cloud",
icon_value=blender.icon("CLOUD"),
)
props.target = "SCREENSHOT"
props.screenshot_full = True
def get_topbar_menu():
"""Return the topbar menu in a Blender 2.79 and 2.80 compatible way."""
try:
menu = bpy.types.TOPBAR_MT_window
except AttributeError:
# Blender < 2.80
menu = bpy.types.INFO_MT_window
return menu
def register():
bpy.utils.register_class(PILLAR_OT_image_share)
bpy.types.IMAGE_MT_image.append(image_editor_menu)
get_topbar_menu().append(window_menu)
def unregister():
bpy.utils.unregister_class(PILLAR_OT_image_share)
bpy.types.IMAGE_MT_image.remove(image_editor_menu)
get_topbar_menu().remove(window_menu)

969
blender_cloud/pillar.py Executable file → Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,180 +0,0 @@
"""Handle saving and loading project-specific settings."""
import contextlib
import logging
import typing
# Names of BlenderCloudPreferences properties that are both project-specific
# and simple enough to store directly in a dict.
PROJECT_SPECIFIC_SIMPLE_PROPS = ("cloud_project_local_path",)
# Names of BlenderCloudPreferences properties that are project-specific and
# Flamenco Manager-specific, and simple enough to store in a dict.
FLAMENCO_PER_PROJECT_PER_MANAGER = (
"flamenco_exclude_filter",
"flamenco_job_file_path",
"flamenco_job_output_path",
"flamenco_job_output_strip_components",
"flamenco_relative_only",
)
log = logging.getLogger(__name__)
project_settings_loading = 0 # counter, if > 0 then we're loading stuff.
@contextlib.contextmanager
def mark_as_loading():
"""Sets project_settings_loading > 0 while the context is active.
A counter is used to allow for nested mark_as_loading() contexts.
"""
global project_settings_loading
project_settings_loading += 1
try:
yield
finally:
project_settings_loading -= 1
def update_preferences(
prefs,
names_to_update: typing.Iterable[str],
new_values: typing.Mapping[str, typing.Any],
):
for name in names_to_update:
if not hasattr(prefs, name):
log.debug("not setting %r, property cannot be found", name)
continue
if name in new_values:
log.debug("setting %r = %r", name, new_values[name])
setattr(prefs, name, new_values[name])
else:
# The property wasn't stored, so set the default value instead.
bl_type, args = getattr(prefs.bl_rna, name)
log.debug("finding default value for %r", name)
if "default" not in args:
log.debug("no default value for %r, not touching", name)
continue
log.debug("found default value for %r = %r", name, args["default"])
setattr(prefs, name, args["default"])
def handle_project_update(_=None, _2=None):
"""Handles changing projects, which may cause extensions to be disabled/enabled.
Ignores arguments so that it can be used as property update callback.
"""
from .blender import preferences, project_extensions
with mark_as_loading():
prefs = preferences()
project_id = prefs.project.project
log.debug(
"Updating internal state to reflect extensions enabled on current project %s.",
project_id,
)
project_extensions.cache_clear()
from blender_cloud import attract, flamenco
attract.deactivate()
flamenco.deactivate()
enabled_for = project_extensions(project_id)
log.info("Project extensions: %s", enabled_for)
if "attract" in enabled_for:
attract.activate()
if "flamenco" in enabled_for:
flamenco.activate()
# Load project-specific settings from the last time we visited this project.
ps = prefs.get("project_settings", {}).get(project_id, {})
if not ps:
log.debug(
"no project-specific settings are available, "
"only resetting available Flamenco Managers"
)
# The Flamenco Manager should really be chosen explicitly out of the available
# Managers.
prefs.flamenco_manager.available_managers = []
return
if log.isEnabledFor(logging.DEBUG):
from pprint import pformat
log.debug("loading project-specific settings:\n%s", pformat(ps.to_dict()))
# Restore simple properties.
update_preferences(prefs, PROJECT_SPECIFIC_SIMPLE_PROPS, ps)
# Restore Flamenco settings.
prefs.flamenco_manager.available_managers = ps.get(
"flamenco_available_managers", []
)
flamenco_manager_id = ps.get("flamenco_manager_id")
if flamenco_manager_id:
log.debug("setting flamenco manager to %s", flamenco_manager_id)
try:
# This will trigger a load of Project+Manager-specfic settings.
prefs.flamenco_manager.manager = flamenco_manager_id
except TypeError:
log.warning(
"manager %s for this project could not be found",
flamenco_manager_id,
)
elif prefs.flamenco_manager.available_managers:
prefs.flamenco_manager.manager = prefs.flamenco_manager.available_managers[
0
]["_id"]
def store(_=None, _2=None):
"""Remember project-specific settings as soon as one of them changes.
Ignores arguments so that it can be used as property update callback.
No-op when project_settings_loading=True, to prevent saving project-
specific settings while they are actually being loaded.
"""
from .blender import preferences
global project_settings_loading
if project_settings_loading:
return
prefs = preferences()
project_id = prefs.project.project
all_settings = prefs.get("project_settings", {})
ps = all_settings.get(project_id, {}) # either a dict or bpy.types.IDPropertyGroup
for name in PROJECT_SPECIFIC_SIMPLE_PROPS:
ps[name] = getattr(prefs, name)
# Store project-specific Flamenco settings
ps["flamenco_manager_id"] = prefs.flamenco_manager.manager
ps["flamenco_available_managers"] = prefs.flamenco_manager.available_managers
# Store per-project, per-manager settings for the current Manager.
pppm = ps.get("flamenco_managers_settings", {})
pppm[prefs.flamenco_manager.manager] = {
name: getattr(prefs, name) for name in FLAMENCO_PER_PROJECT_PER_MANAGER
}
ps[
"flamenco_managers_settings"
] = pppm # IDPropertyGroup has no setdefault() method.
# Store this project's settings in the preferences.
all_settings[project_id] = ps
prefs["project_settings"] = all_settings
if log.isEnabledFor(logging.DEBUG):
from pprint import pformat
if hasattr(all_settings, "to_dict"):
to_log = all_settings.to_dict()
else:
to_log = all_settings
log.debug("Saving project-specific settings:\n%s", pformat(to_log))

View File

@ -1,585 +0,0 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
"""Synchronises settings & startup file with the Cloud.
Caching is disabled on many PillarSDK calls, as synchronisation can happen
rapidly between multiple machines. This means that information can be outdated
in seconds, rather than the minutes the cache system assumes.
"""
import functools
import logging
import pathlib
import tempfile
import typing
import shutil
import bpy
import asyncio
import pillarsdk
from pillarsdk import exceptions as sdk_exceptions
from .pillar import pillar_call
from . import async_loop, blender, pillar, cache, blendfile, home_project
SETTINGS_FILES_TO_UPLOAD = ["userpref.blend", "startup.blend"]
# These are RNA keys inside the userpref.blend file, and their
# Python properties names. These settings will not be synced.
LOCAL_SETTINGS_RNA = [
(b"dpi", "system.dpi"),
(b"virtual_pixel", "system.virtual_pixel_mode"),
(b"compute_device_id", "system.compute_device"),
(b"compute_device_type", "system.compute_device_type"),
(b"fontdir", "filepaths.font_directory"),
(b"textudir", "filepaths.texture_directory"),
(b"renderdir", "filepaths.render_output_directory"),
(b"pythondir", "filepaths.script_directory"),
(b"sounddir", "filepaths.sound_directory"),
(b"tempdir", "filepaths.temporary_directory"),
(b"render_cachedir", "filepaths.render_cache_directory"),
(b"i18ndir", "filepaths.i18n_branches_directory"),
(b"image_editor", "filepaths.image_editor"),
(b"anim_player", "filepaths.animation_player"),
]
REQUIRES_ROLES_FOR_SYNC = set() # no roles needed.
SYNC_GROUP_NODE_NAME = "Blender Sync"
SYNC_GROUP_NODE_DESC = (
"The [Blender Cloud Addon](https://cloud.blender.org/services"
"#blender-addon) will synchronize your Blender settings here."
)
log = logging.getLogger(__name__)
def set_blender_sync_status(set_status: str):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
bss = bpy.context.window_manager.blender_sync_status
bss.status = set_status
try:
return func(*args, **kwargs)
finally:
bss.status = "IDLE"
return wrapper
return decorator
def async_set_blender_sync_status(set_status: str):
def decorator(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
bss = bpy.context.window_manager.blender_sync_status
bss.status = set_status
try:
return await func(*args, **kwargs)
finally:
bss.status = "IDLE"
return wrapper
return decorator
async def find_sync_group_id(
home_project_id: str, user_id: str, blender_version: str, *, may_create=True
) -> typing.Tuple[str, str]:
"""Finds the group node in which to store sync assets.
If the group node doesn't exist and may_create=True, it creates it.
"""
# Find the top-level sync group node. This should have been
# created by Pillar while creating the home project.
try:
sync_group, created = await pillar.find_or_create_node(
where={
"project": home_project_id,
"node_type": "group",
"parent": None,
"name": SYNC_GROUP_NODE_NAME,
"user": user_id,
},
projection={"_id": 1},
may_create=False,
)
except pillar.PillarError:
raise pillar.PillarError("Unable to find sync folder on the Cloud")
if not may_create and sync_group is None:
log.info("Sync folder doesn't exist, and not creating it either.")
return "", ""
# Find/create the sub-group for the requested Blender version
try:
sub_sync_group, created = await pillar.find_or_create_node(
where={
"project": home_project_id,
"node_type": "group",
"parent": sync_group["_id"],
"name": blender_version,
"user": user_id,
},
additional_create_props={
"description": "Sync folder for Blender %s" % blender_version,
"properties": {"status": "published"},
},
projection={"_id": 1},
may_create=may_create,
)
except pillar.PillarError:
raise pillar.PillarError("Unable to create sync folder on the Cloud")
if not may_create and sub_sync_group is None:
log.info(
"Sync folder for Blender version %s doesn't exist, "
"and not creating it either.",
blender_version,
)
return sync_group["_id"], ""
return sync_group["_id"], sub_sync_group["_id"]
@functools.lru_cache()
async def available_blender_versions(home_project_id: str, user_id: str) -> list:
bss = bpy.context.window_manager.blender_sync_status
# Get the available Blender versions.
sync_group = await pillar_call(
pillarsdk.Node.find_first,
params={
"where": {
"project": home_project_id,
"node_type": "group",
"parent": None,
"name": SYNC_GROUP_NODE_NAME,
"user": user_id,
},
"projection": {"_id": 1},
},
caching=False,
)
if sync_group is None:
bss.report({"ERROR"}, "No synced Blender settings in your Blender Cloud")
log.debug(
"-- unable to find sync group for home_project_id=%r and user_id=%r",
home_project_id,
user_id,
)
return []
sync_nodes = await pillar_call(
pillarsdk.Node.all,
params={
"where": {
"project": home_project_id,
"node_type": "group",
"parent": sync_group["_id"],
"user": user_id,
},
"projection": {"_id": 1, "name": 1},
"sort": "-name",
},
caching=False,
)
if not sync_nodes or not sync_nodes._items:
bss.report({"ERROR"}, "No synced Blender settings in your Blender Cloud.")
return []
versions = [node.name for node in sync_nodes._items]
log.debug("Versions: %s", versions)
return versions
# noinspection PyAttributeOutsideInit
class PILLAR_OT_sync(
pillar.PillarOperatorMixin, async_loop.AsyncModalOperatorMixin, bpy.types.Operator
):
bl_idname = "pillar.sync"
bl_label = "Synchronise with Blender Cloud"
bl_description = "Synchronises Blender settings with Blender Cloud"
log = logging.getLogger("bpy.ops.%s" % bl_idname)
home_project_id = ""
sync_group_id = "" # top-level sync group node ID
sync_group_versioned_id = "" # sync group node ID for the given Blender version.
action: bpy.props.EnumProperty(
items=[
("PUSH", "Push", "Push settings to the Blender Cloud"),
("PULL", "Pull", "Pull settings from the Blender Cloud"),
("REFRESH", "Refresh", "Refresh available versions"),
("SELECT", "Select", "Select version to sync"),
],
name="action",
)
CURRENT_BLENDER_VERSION = "%i.%i" % bpy.app.version[:2]
blender_version: bpy.props.StringProperty(
name="blender_version",
description="Blender version to sync for",
default=CURRENT_BLENDER_VERSION,
)
def bss_report(self, level, message):
bss = bpy.context.window_manager.blender_sync_status
bss.report(level, message)
def invoke(self, context, event):
if self.action == "SELECT":
# Synchronous action
return self.action_select(context)
if self.action in {"PUSH", "PULL"} and not self.blender_version:
self.bss_report({"ERROR"}, "No Blender version to sync for was given.")
return {"CANCELLED"}
return async_loop.AsyncModalOperatorMixin.invoke(self, context, event)
def action_select(self, context):
"""Allows selection of the Blender version to use.
This is a synchronous action, as it requires a dialog box.
"""
self.log.info("Performing action SELECT")
# Do a refresh before we can show the dropdown.
fut = asyncio.ensure_future(
self.async_execute(context, action_override="REFRESH")
)
loop = asyncio.get_event_loop()
loop.run_until_complete(fut)
self._state = "SELECTING"
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
bss = bpy.context.window_manager.blender_sync_status
self.layout.prop(bss, "version", text="Blender version")
def execute(self, context):
if self.action != "SELECT":
log.debug("Ignoring execute() for action %r", self.action)
return {"FINISHED"}
log.debug("Performing execute() for action %r", self.action)
# Perform the sync when the user closes the dialog box.
bss = bpy.context.window_manager.blender_sync_status
bpy.ops.pillar.sync(
"INVOKE_DEFAULT", action="PULL", blender_version=bss.version
)
return {"FINISHED"}
@async_set_blender_sync_status("SYNCING")
async def async_execute(self, context, *, action_override=None):
"""Entry point of the asynchronous operator."""
action = action_override or self.action
self.bss_report({"INFO"}, "Communicating with Blender Cloud")
self.log.info("Performing action %s", action)
try:
# Refresh credentials
try:
db_user = await self.check_credentials(context, REQUIRES_ROLES_FOR_SYNC)
self.user_id = db_user["_id"]
log.debug("Found user ID: %s", self.user_id)
except pillar.NotSubscribedToCloudError as ex:
self._log_subscription_needed(can_renew=ex.can_renew)
self._state = "QUIT"
return
except pillar.UserNotLoggedInError:
self.log.exception("Error checking/refreshing credentials.")
self.bss_report({"ERROR"}, "Please log in on Blender ID first.")
self._state = "QUIT"
return
# Find the home project.
try:
self.home_project_id = await home_project.get_home_project_id()
except sdk_exceptions.ForbiddenAccess:
self.log.exception("Forbidden access to home project.")
self.bss_report({"ERROR"}, "Did not get access to home project.")
self._state = "QUIT"
return
except sdk_exceptions.ResourceNotFound:
self.bss_report({"ERROR"}, "Home project not found.")
self._state = "QUIT"
return
# Only create the folder structure if we're pushing.
may_create = self.action == "PUSH"
try:
gid, subgid = await find_sync_group_id(
self.home_project_id,
self.user_id,
self.blender_version,
may_create=may_create,
)
self.sync_group_id = gid
self.sync_group_versioned_id = subgid
self.log.debug("Found top-level group node ID: %s", self.sync_group_id)
self.log.debug(
"Found group node ID for %s: %s",
self.blender_version,
self.sync_group_versioned_id,
)
except sdk_exceptions.ForbiddenAccess:
self.log.exception("Unable to find Group ID")
self.bss_report({"ERROR"}, "Unable to find sync folder.")
self._state = "QUIT"
return
# Perform the requested action.
action_method = {
"PUSH": self.action_push,
"PULL": self.action_pull,
"REFRESH": self.action_refresh,
}[action]
await action_method(context)
except Exception as ex:
self.log.exception("Unexpected exception caught.")
self.bss_report({"ERROR"}, "Unexpected error: %s" % ex)
self._state = "QUIT"
async def action_push(self, context):
"""Sends files to the Pillar server."""
self.log.info("Saved user preferences to disk before pushing to cloud.")
bpy.ops.wm.save_userpref()
config_dir = pathlib.Path(bpy.utils.user_resource("CONFIG"))
for fname in SETTINGS_FILES_TO_UPLOAD:
path = config_dir / fname
if not path.exists():
self.log.debug("Skipping non-existing %s", path)
continue
if self.signalling_future.cancelled():
self.bss_report({"WARNING"}, "Upload aborted.")
return
self.bss_report({"INFO"}, "Uploading %s" % fname)
try:
await pillar.attach_file_to_group(
path,
self.home_project_id,
self.sync_group_versioned_id,
self.user_id,
)
except sdk_exceptions.RequestEntityTooLarge as ex:
self.log.error("File too big to upload: %s" % ex)
self.log.error(
"To upload larger files, please subscribe to Blender Cloud."
)
self.bss_report(
{"SUBSCRIBE"},
"File %s too big to upload. "
"Subscribe for unlimited space." % fname,
)
self._state = "QUIT"
return
await self.action_refresh(context)
# After pushing, change the 'pull' version to the current version of Blender.
# Or to the latest version, if by some mistake somewhere the current push
# isn't available after all.
bss = bpy.context.window_manager.blender_sync_status
if self.CURRENT_BLENDER_VERSION in bss.available_blender_versions:
bss.version = self.CURRENT_BLENDER_VERSION
else:
bss.version = max(bss.available_blender_versions)
self.bss_report({"INFO"}, "Settings pushed to Blender Cloud.")
async def action_pull(self, context):
"""Loads files from the Pillar server."""
# If the sync group node doesn't exist, offer a list of groups that do.
if not self.sync_group_id:
self.bss_report(
{"ERROR"}, "There are no synced Blender settings in your Blender Cloud."
)
return
if not self.sync_group_versioned_id:
self.bss_report(
{"ERROR"},
"Therre are no synced Blender settings for version %s"
% self.blender_version,
)
return
self.bss_report({"INFO"}, "Pulling settings from Blender Cloud")
with tempfile.TemporaryDirectory(prefix="bcloud-sync") as tempdir:
for fname in SETTINGS_FILES_TO_UPLOAD:
await self.download_settings_file(fname, tempdir)
self.bss_report(
{"WARNING"}, "Settings pulled from Cloud, restart Blender to load them."
)
async def action_refresh(self, context):
self.bss_report({"INFO"}, "Refreshing available Blender versions.")
# Clear the LRU cache of available_blender_versions so that we can
# obtain new versions (if someone synced from somewhere else, for example)
available_blender_versions.cache_clear()
versions = await available_blender_versions(self.home_project_id, self.user_id)
bss = bpy.context.window_manager.blender_sync_status
bss.available_blender_versions = versions
if not versions:
# There are versions to sync, so we can remove the status message.
# However, if there aren't any, the status message shows why, and
# shouldn't be erased.
return
# Prevent warnings that the current value of the EnumProperty isn't valid.
current_version = "%d.%d" % bpy.app.version[:2]
if current_version in versions:
bss.version = current_version
else:
bss.version = versions[0]
self.bss_report({"INFO"}, "")
async def download_settings_file(self, fname: str, temp_dir: str):
config_dir = pathlib.Path(bpy.utils.user_resource("CONFIG"))
meta_path = cache.cache_directory("home-project", "blender-sync")
self.bss_report({"INFO"}, "Downloading %s from Cloud" % fname)
# Get the asset node
node_props = {
"project": self.home_project_id,
"node_type": "asset",
"parent": self.sync_group_versioned_id,
"name": fname,
}
node = await pillar_call(
pillarsdk.Node.find_first,
{"where": node_props, "projection": {"_id": 1, "properties.file": 1}},
caching=False,
)
if node is None:
self.bss_report({"INFO"}, "Unable to find %s on Blender Cloud" % fname)
self.log.info("Unable to find node on Blender Cloud for %s", fname)
return
async def file_downloaded(
file_path: str, file_desc: pillarsdk.File, map_type: str
):
# Allow the caller to adjust the file before we move it into place.
if fname.lower() == "userpref.blend":
await self.update_userpref_blend(file_path)
# Move the file next to the final location; as it may be on a
# different filesystem than the temporary directory, this can
# fail, and we don't want to destroy the existing file.
local_temp = config_dir / (fname + "~")
local_final = config_dir / fname
# Make a backup copy of the file as it was before pulling.
if local_final.exists():
local_bak = config_dir / (fname + "-pre-bcloud-pull")
self.move_file(local_final, local_bak)
self.move_file(file_path, local_temp)
self.move_file(local_temp, local_final)
file_id = node.properties.file
await pillar.download_file_by_uuid(
file_id,
temp_dir,
str(meta_path),
file_loaded_sync=file_downloaded,
future=self.signalling_future,
)
def move_file(self, src, dst):
self.log.info("Moving %s to %s", src, dst)
shutil.move(str(src), str(dst))
async def update_userpref_blend(self, file_path: str):
self.log.info("Overriding machine-local settings in %s", file_path)
# Remember some settings that should not be overwritten from the Cloud.
prefs = blender.ctx_preferences()
remembered = {}
for rna_key, python_key in LOCAL_SETTINGS_RNA:
assert (
"." in python_key
), "Sorry, this code assumes there is a dot in the Python key"
try:
value = prefs.path_resolve(python_key)
except ValueError:
# Setting doesn't exist. This can happen, for example Cycles
# settings on a build that doesn't have Cycles enabled.
continue
# Map enums from strings (in Python) to ints (in DNA).
dot_index = python_key.rindex(".")
parent_key, prop_key = python_key[:dot_index], python_key[dot_index + 1 :]
parent = prefs.path_resolve(parent_key)
prop = parent.bl_rna.properties[prop_key]
if prop.type == "ENUM":
log.debug(
"Rewriting %s from %r to %r",
python_key,
value,
prop.enum_items[value].value,
)
value = prop.enum_items[value].value
else:
log.debug("Keeping value of %s: %r", python_key, value)
remembered[rna_key] = value
log.debug("Overriding values: %s", remembered)
# Rewrite the userprefs.blend file to override the options.
with blendfile.open_blend(file_path, "rb+") as blend:
prefs = next(block for block in blend.blocks if block.code == b"USER")
for key, value in remembered.items():
self.log.debug("prefs[%r] = %r" % (key, prefs[key]))
self.log.debug(" -> setting prefs[%r] = %r" % (key, value))
prefs[key] = value
def register():
bpy.utils.register_class(PILLAR_OT_sync)
def unregister():
bpy.utils.unregister_class(PILLAR_OT_sync)

View File

@ -1,910 +0,0 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import asyncio
import logging
import os
import threading
import typing
import bpy
import bgl
import pillarsdk
from .. import async_loop, pillar, cache, blender, utils
from . import (
menu_item as menu_item_mod,
) # so that we can have menu items called 'menu_item'
from . import draw, nodes
REQUIRED_ROLES_FOR_TEXTURE_BROWSER = {"subscriber", "demo"}
MOUSE_SCROLL_PIXELS_PER_TICK = 50
TARGET_ITEM_WIDTH = 400
TARGET_ITEM_HEIGHT = 128
ITEM_MARGIN_X = 5
ITEM_MARGIN_Y = 5
ITEM_PADDING_X = 5
log = logging.getLogger(__name__)
class BlenderCloudBrowser(
pillar.PillarOperatorMixin, async_loop.AsyncModalOperatorMixin, bpy.types.Operator
):
bl_idname = "pillar.browser"
bl_label = "Blender Cloud Texture Browser"
_draw_handle = None
current_path = pillar.CloudPath("/")
project_name = ""
# This contains a stack of Node objects that lead up to the currently browsed node.
path_stack = [] # type: typing.List[pillarsdk.Node]
# This contains a stack of MenuItem objects that lead up to the currently browsed node.
menu_item_stack = [] # type: typing.List[menu_item_mod.MenuItem]
timer = None
log = logging.getLogger("%s.BlenderCloudBrowser" % __name__)
_menu_item_lock = threading.Lock()
current_display_content = [] # type: typing.List[menu_item_mod.MenuItem]
loaded_images = set() # type: typing.Set[str]
thumbnails_cache = ""
maximized_area = False
mouse_x = 0
mouse_y = 0
scroll_offset = 0
scroll_offset_target = 0
scroll_offset_max = 0
scroll_offset_space_left = 0
def invoke(self, context, event):
# Refuse to start if the file hasn't been saved. It's okay if
# it's dirty, we just need to know where '//' points to.
if not os.path.exists(context.blend_data.filepath):
self.report(
{"ERROR"},
"Please save your Blend file before using " "the Blender Cloud addon.",
)
return {"CANCELLED"}
wm = context.window_manager
self.current_path = pillar.CloudPath(wm.last_blender_cloud_location)
self.path_stack = [] # list of nodes that make up the current path.
self.thumbnails_cache = cache.cache_directory("thumbnails")
self.mouse_x = event.mouse_x
self.mouse_y = event.mouse_y
# See if we have to maximize the current area
if not context.screen.show_fullscreen:
self.maximized_area = True
bpy.ops.screen.screen_full_area(use_hide_panels=True)
# Add the region OpenGL drawing callback
# draw in view space with 'POST_VIEW' and 'PRE_VIEW'
self._draw_handle = context.space_data.draw_handler_add(
self.draw_menu, (context,), "WINDOW", "POST_PIXEL"
)
self.current_display_content = []
self.loaded_images = set()
self._scroll_reset()
context.window.cursor_modal_set("DEFAULT")
return async_loop.AsyncModalOperatorMixin.invoke(self, context, event)
def modal(self, context, event):
result = async_loop.AsyncModalOperatorMixin.modal(self, context, event)
if not {"PASS_THROUGH", "RUNNING_MODAL"}.intersection(result):
return result
if event.type == "TAB" and event.value == "RELEASE":
self.log.info("Ensuring async loop is running")
async_loop.ensure_async_loop()
if event.type == "TIMER":
self._scroll_smooth()
context.area.tag_redraw()
return {"RUNNING_MODAL"}
if "MOUSE" in event.type:
context.area.tag_redraw()
self.mouse_x = event.mouse_x
self.mouse_y = event.mouse_y
left_mouse_release = event.type == "LEFTMOUSE" and event.value == "RELEASE"
if left_mouse_release and self._state in {"PLEASE_SUBSCRIBE", "PLEASE_RENEW"}:
self.open_browser_subscribe(renew=self._state == "PLEASE_RENEW")
self._finish(context)
return {"FINISHED"}
if self._state == "BROWSING":
selected = self.get_clicked()
if selected:
if selected.is_spinning:
context.window.cursor_set("WAIT")
else:
context.window.cursor_set("HAND")
else:
context.window.cursor_set("DEFAULT")
# Scrolling
if event.type == "WHEELUPMOUSE":
self._scroll_by(MOUSE_SCROLL_PIXELS_PER_TICK)
context.area.tag_redraw()
elif event.type == "WHEELDOWNMOUSE":
self._scroll_by(-MOUSE_SCROLL_PIXELS_PER_TICK)
context.area.tag_redraw()
elif event.type == "TRACKPADPAN":
self._scroll_by(event.mouse_prev_y - event.mouse_y, smooth=False)
context.area.tag_redraw()
if left_mouse_release:
if selected is None:
# No item clicked, ignore it.
return {"RUNNING_MODAL"}
if selected.is_spinning:
# This can happen when the thumbnail information isn't loaded yet.
return {"RUNNING_MODAL"}
if selected.is_folder:
self.descend_node(selected)
else:
self.handle_item_selection(context, selected)
if event.type in {"RIGHTMOUSE", "ESC"}:
self._finish(context)
return {"CANCELLED"}
return {"RUNNING_MODAL"}
async def async_execute(self, context):
self._state = "CHECKING_CREDENTIALS"
self.log.debug("Checking credentials")
try:
db_user = await self.check_credentials(
context, REQUIRED_ROLES_FOR_TEXTURE_BROWSER
)
except pillar.NotSubscribedToCloudError as ex:
self._log_subscription_needed(can_renew=ex.can_renew, level="INFO")
self._show_subscribe_screen(can_renew=ex.can_renew)
return None
if db_user is None:
raise pillar.UserNotLoggedInError()
await self.async_download_previews()
def _show_subscribe_screen(self, *, can_renew: bool):
"""Shows the "You need to subscribe" screen."""
if can_renew:
self._state = "PLEASE_RENEW"
else:
self._state = "PLEASE_SUBSCRIBE"
bpy.context.window.cursor_set("HAND")
def descend_node(self, menu_item: menu_item_mod.MenuItem):
"""Descends the node hierarchy by visiting this menu item's node.
Also keeps track of the current node, so that we know where the "up" button should go.
"""
node = menu_item.node
assert isinstance(node, pillarsdk.Node), "Wrong type %s" % node
if isinstance(node, nodes.UpNode):
# Going up.
self.log.debug("Going up to %r", self.current_path)
self.current_path = self.current_path.parent
if self.path_stack:
self.path_stack.pop()
if self.menu_item_stack:
self.menu_item_stack.pop()
if not self.path_stack:
self.project_name = ""
else:
# Going down, keep track of where we were
if isinstance(node, nodes.ProjectNode):
self.project_name = node["name"]
self.current_path /= node["_id"]
self.log.debug("Going down to %r", self.current_path)
self.path_stack.append(node)
self.menu_item_stack.append(menu_item)
self.browse_assets()
@property
def node(self):
if not self.path_stack:
return None
return self.path_stack[-1]
def _finish(self, context):
self.log.debug("Finishing the modal operator")
async_loop.AsyncModalOperatorMixin._finish(self, context)
self.clear_images()
context.space_data.draw_handler_remove(self._draw_handle, "WINDOW")
context.window.cursor_modal_restore()
if self.maximized_area:
bpy.ops.screen.screen_full_area(use_hide_panels=True)
context.area.tag_redraw()
self.log.debug("Modal operator finished")
def clear_images(self):
"""Removes all images we loaded from Blender's memory."""
for image in bpy.data.images:
if image.filepath_raw not in self.loaded_images:
continue
image.user_clear()
bpy.data.images.remove(image)
self.loaded_images.clear()
self.current_display_content.clear()
def add_menu_item(self, *args) -> menu_item_mod.MenuItem:
menu_item = menu_item_mod.MenuItem(*args)
# Just make this thread-safe to be on the safe side.
with self._menu_item_lock:
self.current_display_content.append(menu_item)
if menu_item.icon is not None:
self.loaded_images.add(menu_item.icon.filepath_raw)
self.sort_menu()
return menu_item
def update_menu_item(self, node, *args):
node_uuid = node["_id"]
# Just make this thread-safe to be on the safe side.
with self._menu_item_lock:
for menu_item in self.current_display_content:
if menu_item.represents(node):
menu_item.update(node, *args)
self.loaded_images.add(menu_item.icon.filepath_raw)
break
else:
raise ValueError("Unable to find MenuItem(node_uuid=%r)" % node_uuid)
self.sort_menu()
def sort_menu(self):
"""Sorts the self.current_display_content list."""
if not self.current_display_content:
return
with self._menu_item_lock:
self.current_display_content.sort(key=menu_item_mod.MenuItem.sort_key)
async def async_download_previews(self):
self._state = "BROWSING"
thumbnails_directory = self.thumbnails_cache
self.log.info("Asynchronously downloading previews to %r", thumbnails_directory)
self.log.info("Current BCloud path is %r", self.current_path)
self.clear_images()
self._scroll_reset()
project_uuid = self.current_path.project_uuid
node_uuid = self.current_path.node_uuid
if node_uuid:
# Query for sub-nodes of this node.
self.log.debug("Getting subnodes for parent node %r", node_uuid)
children = await pillar.get_nodes(
parent_node_uuid=node_uuid, node_type={"group_texture", "group_hdri"}
)
elif project_uuid:
# Query for top-level nodes.
self.log.debug("Getting subnodes for project node %r", project_uuid)
children = await pillar.get_nodes(
project_uuid=project_uuid,
parent_node_uuid="",
node_type={"group_texture", "group_hdri"},
)
else:
# Query for projects
self.log.debug(
"No node UUID and no project UUID, listing available projects"
)
children = await pillar.get_texture_projects()
for proj_dict in children:
self.add_menu_item(
nodes.ProjectNode(proj_dict), None, "FOLDER", proj_dict["name"]
)
return
# Make sure we can go up again.
self.add_menu_item(nodes.UpNode(), None, "FOLDER", ".. up ..")
# Download all child nodes
self.log.debug("Iterating over child nodes of %r", self.current_path)
for child in children:
# print(' - %(_id)s = %(name)s' % child)
if child["node_type"] not in menu_item_mod.MenuItem.SUPPORTED_NODE_TYPES:
self.log.debug("Skipping node of type %r", child["node_type"])
continue
self.add_menu_item(child, None, "FOLDER", child["name"])
# There are only sub-nodes at the project level, no texture nodes,
# so we won't have to bother looking for textures.
if not node_uuid:
return
directory = os.path.join(thumbnails_directory, project_uuid, node_uuid)
os.makedirs(directory, exist_ok=True)
self.log.debug("Fetching texture thumbnails for node %r", node_uuid)
def thumbnail_loading(node, texture_node):
self.add_menu_item(node, None, "SPINNER", texture_node["name"])
def thumbnail_loaded(node, file_desc, thumb_path):
self.log.debug("Node %s thumbnail loaded", node["_id"])
self.update_menu_item(node, file_desc, thumb_path)
await pillar.fetch_texture_thumbs(
node_uuid,
"s",
directory,
thumbnail_loading=thumbnail_loading,
thumbnail_loaded=thumbnail_loaded,
future=self.signalling_future,
)
def browse_assets(self):
self.log.debug("Browsing assets at %r", self.current_path)
bpy.context.window_manager.last_blender_cloud_location = str(self.current_path)
self._new_async_task(self.async_download_previews())
def draw_menu(self, context):
"""Draws the GUI with OpenGL."""
drawers = {
"INITIALIZING": self._draw_initializing,
"CHECKING_CREDENTIALS": self._draw_checking_credentials,
"BROWSING": self._draw_browser,
"DOWNLOADING_TEXTURE": self._draw_downloading,
"EXCEPTION": self._draw_exception,
"PLEASE_SUBSCRIBE": self._draw_subscribe,
"PLEASE_RENEW": self._draw_renew,
}
if self._state in drawers:
drawer = drawers[self._state]
drawer(context)
# For debugging: draw the state
draw.text(
(5, 5),
"%s %s" % (self._state, self.project_name),
rgba=(1.0, 1.0, 1.0, 1.0),
fsize=12,
)
@staticmethod
def _window_region(context):
window_regions = [
region for region in context.area.regions if region.type == "WINDOW"
]
return window_regions[0]
def _draw_browser(self, context):
"""OpenGL drawing code for the BROWSING state."""
from . import draw
if not self.current_display_content:
self._draw_text_on_colour(
context, "Communicating with Blender Cloud", (0.0, 0.0, 0.0, 0.6)
)
return
window_region = self._window_region(context)
content_width = window_region.width - ITEM_MARGIN_X * 2
content_height = window_region.height - ITEM_MARGIN_Y * 2
content_x = ITEM_MARGIN_X
content_y = context.area.height - ITEM_MARGIN_Y - TARGET_ITEM_HEIGHT
col_count = content_width // TARGET_ITEM_WIDTH
item_width = (content_width - (col_count * ITEM_PADDING_X)) / col_count
item_height = TARGET_ITEM_HEIGHT
block_width = item_width + ITEM_PADDING_X
block_height = item_height + ITEM_MARGIN_Y
bgl.glEnable(bgl.GL_BLEND)
draw.aabox(
(0, 0), (window_region.width, window_region.height), (0.0, 0.0, 0.0, 0.6)
)
bottom_y = float("inf")
# The -1 / +2 are for extra rows that are drawn only half at the top/bottom.
first_item_idx = max(
0, int(-self.scroll_offset // block_height - 1) * col_count
)
items_per_page = int(content_height // item_height + 2) * col_count
last_item_idx = first_item_idx + items_per_page
for item_idx, item in enumerate(self.current_display_content):
x = content_x + (item_idx % col_count) * block_width
y = content_y - (item_idx // col_count) * block_height - self.scroll_offset
item.update_placement(x, y, item_width, item_height)
if first_item_idx <= item_idx < last_item_idx:
# Only draw if the item is actually on screen.
item.draw(highlighted=item.hits(self.mouse_x, self.mouse_y))
bottom_y = min(y, bottom_y)
self.scroll_offset_space_left = window_region.height - bottom_y
self.scroll_offset_max = (
self.scroll_offset - self.scroll_offset_space_left + 0.25 * block_height
)
bgl.glDisable(bgl.GL_BLEND)
def _draw_downloading(self, context):
"""OpenGL drawing code for the DOWNLOADING_TEXTURE state."""
self._draw_text_on_colour(
context, "Downloading texture from Blender Cloud", (0.0, 0.0, 0.2, 0.6)
)
def _draw_checking_credentials(self, context):
"""OpenGL drawing code for the CHECKING_CREDENTIALS state."""
self._draw_text_on_colour(
context, "Checking login credentials", (0.0, 0.0, 0.2, 0.6)
)
def _draw_initializing(self, context):
"""OpenGL drawing code for the INITIALIZING state."""
self._draw_text_on_colour(context, "Initializing", (0.0, 0.0, 0.2, 0.6))
def _draw_text_on_colour(self, context, text: str, bgcolour):
content_height, content_width = self._window_size(context)
bgl.glEnable(bgl.GL_BLEND)
draw.aabox((0, 0), (content_width, content_height), bgcolour)
draw.text(
(content_width * 0.5, content_height * 0.7), text, fsize=20, align="C"
)
bgl.glDisable(bgl.GL_BLEND)
def _window_size(self, context):
window_region = self._window_region(context)
content_width = window_region.width
content_height = window_region.height
return content_height, content_width
def _draw_exception(self, context):
"""OpenGL drawing code for the EXCEPTION state."""
import textwrap
content_height, content_width = self._window_size(context)
bgl.glEnable(bgl.GL_BLEND)
draw.aabox((0, 0), (content_width, content_height), (0.2, 0.0, 0.0, 0.6))
ex = self.async_task.exception()
if isinstance(ex, pillar.UserNotLoggedInError):
ex_msg = (
"You are not logged in on Blender ID. Please log in at User Preferences, "
"Add-ons, Blender ID Authentication."
)
else:
ex_msg = str(ex)
if not ex_msg:
ex_msg = str(type(ex))
text = "An error occurred:\n%s" % ex_msg
lines = textwrap.wrap(text, width=100)
draw.text((content_width * 0.1, content_height * 0.9), lines, fsize=16)
bgl.glDisable(bgl.GL_BLEND)
def _draw_subscribe(self, context):
self._draw_text_on_colour(
context, "Click to subscribe to the Blender Cloud", (0.0, 0.0, 0.2, 0.6)
)
def _draw_renew(self, context):
self._draw_text_on_colour(
context,
"Click to renew your Blender Cloud subscription",
(0.0, 0.0, 0.2, 0.6),
)
def get_clicked(self) -> typing.Optional[menu_item_mod.MenuItem]:
for item in self.current_display_content:
if item.hits(self.mouse_x, self.mouse_y):
return item
return None
def handle_item_selection(self, context, item: menu_item_mod.MenuItem):
"""Called when the user clicks on a menu item that doesn't represent a folder."""
from pillarsdk.utils import sanitize_filename
self.clear_images()
self._state = "DOWNLOADING_TEXTURE"
node_path_components = (
node["name"] for node in self.path_stack if node is not None
)
local_path_components = [
sanitize_filename(comp) for comp in node_path_components
]
top_texture_directory = bpy.path.abspath(context.scene.local_texture_dir)
local_path = os.path.join(top_texture_directory, *local_path_components)
meta_path = os.path.join(top_texture_directory, ".blender_cloud")
self.log.info("Downloading texture %r to %s", item.node_uuid, local_path)
self.log.debug("Metadata will be stored at %s", meta_path)
file_paths = []
select_dblock = None
node = item.node
def texture_downloading(file_path, *_):
self.log.info("Texture downloading to %s", file_path)
def texture_downloaded(file_path, file_desc, map_type):
nonlocal select_dblock
self.log.info("Texture downloaded to %r.", file_path)
if context.scene.local_texture_dir.startswith("//"):
file_path = bpy.path.relpath(file_path)
image_dblock = bpy.data.images.load(filepath=file_path)
image_dblock["bcloud_file_uuid"] = file_desc["_id"]
image_dblock["bcloud_node_uuid"] = node["_id"]
image_dblock["bcloud_node_type"] = node["node_type"]
image_dblock["bcloud_node"] = pillar.node_to_id(node)
if node["node_type"] == "hdri":
# All HDRi variations should use the same image datablock, hence once name.
image_dblock.name = node["name"]
else:
# All texture variations are loaded at once, and thus need the map type in the name.
image_dblock.name = "%s-%s" % (node["name"], map_type)
# Select the image in the image editor (if the context is right).
# Just set the first image we download,
if context.area.type == "IMAGE_EDITOR":
if select_dblock is None or file_desc.map_type == "color":
select_dblock = image_dblock
context.space_data.image = select_dblock
file_paths.append(file_path)
def texture_download_completed(_):
self.log.info(
"Texture download complete, inspect:\n%s", "\n".join(file_paths)
)
self._state = "QUIT"
# For HDRi nodes: only download the first file.
download_node = pillarsdk.Node.new(node)
if node["node_type"] == "hdri":
download_node.properties.files = [download_node.properties.files[0]]
signalling_future = asyncio.Future()
self._new_async_task(
pillar.download_texture(
download_node,
local_path,
metadata_directory=meta_path,
texture_loading=texture_downloading,
texture_loaded=texture_downloaded,
future=signalling_future,
)
)
self.async_task.add_done_callback(texture_download_completed)
def open_browser_subscribe(self, *, renew: bool):
import webbrowser
url = "renew" if renew else "join"
webbrowser.open_new_tab("https://cloud.blender.org/%s" % url)
self.report({"INFO"}, "We just started a browser for you.")
def _scroll_smooth(self):
diff = self.scroll_offset_target - self.scroll_offset
if diff == 0:
return
if abs(round(diff)) < 1:
self.scroll_offset = self.scroll_offset_target
return
self.scroll_offset += diff * 0.5
def _scroll_by(self, amount, *, smooth=True):
# Slow down scrolling up
if smooth and amount < 0 and -amount > self.scroll_offset_space_left / 4:
amount = -self.scroll_offset_space_left / 4
self.scroll_offset_target = min(
0, max(self.scroll_offset_max, self.scroll_offset_target + amount)
)
if not smooth:
self._scroll_offset = self.scroll_offset_target
def _scroll_reset(self):
self.scroll_offset_target = self.scroll_offset = 0
class PILLAR_OT_switch_hdri(
pillar.PillarOperatorMixin, async_loop.AsyncModalOperatorMixin, bpy.types.Operator
):
bl_idname = "pillar.switch_hdri"
bl_label = "Switch with another variation"
bl_description = (
"Downloads the selected variation of an HDRi, " "replacing the current image"
)
log = logging.getLogger("bpy.ops.%s" % bl_idname)
image_name: bpy.props.StringProperty(
name="image_name", description="Name of the image block to replace"
)
file_uuid: bpy.props.StringProperty(
name="file_uuid", description="File ID to download"
)
async def async_execute(self, context):
"""Entry point of the asynchronous operator."""
self.report({"INFO"}, "Communicating with Blender Cloud")
try:
try:
db_user = await self.check_credentials(
context, REQUIRED_ROLES_FOR_TEXTURE_BROWSER
)
user_id = db_user["_id"]
except pillar.NotSubscribedToCloudError as ex:
self._log_subscription_needed(can_renew=ex.can_renew)
self._state = "QUIT"
return
except pillar.UserNotLoggedInError:
self.log.exception("Error checking/refreshing credentials.")
self.report({"ERROR"}, "Please log in on Blender ID first.")
self._state = "QUIT"
return
if not user_id:
raise pillar.UserNotLoggedInError()
await self.download_and_replace(context)
except Exception as ex:
self.log.exception("Unexpected exception caught.")
self.report({"ERROR"}, "Unexpected error %s: %s" % (type(ex), ex))
self._state = "QUIT"
async def download_and_replace(self, context):
self._state = "DOWNLOADING_TEXTURE"
current_image = bpy.data.images[self.image_name]
node = current_image["bcloud_node"]
filename = "%s.taken_from_file" % pillar.sanitize_filename(node["name"])
local_path = os.path.dirname(bpy.path.abspath(current_image.filepath))
top_texture_directory = bpy.path.abspath(context.scene.local_texture_dir)
meta_path = os.path.join(top_texture_directory, ".blender_cloud")
file_uuid = self.file_uuid
resolution = next(
file_ref["resolution"]
for file_ref in node["properties"]["files"]
if file_ref["file"] == file_uuid
)
my_log = self.log
my_log.info("Downloading file %r-%s to %s", file_uuid, resolution, local_path)
my_log.debug("Metadata will be stored at %s", meta_path)
def file_loading(file_path, file_desc, map_type):
my_log.info(
"Texture downloading to %s (%s)",
file_path,
utils.sizeof_fmt(file_desc["length"]),
)
async def file_loaded(file_path, file_desc, map_type):
if context.scene.local_texture_dir.startswith("//"):
file_path = bpy.path.relpath(file_path)
my_log.info("Texture downloaded to %s", file_path)
current_image["bcloud_file_uuid"] = file_uuid
current_image.filepath = (
file_path # This automatically reloads the image from disk.
)
# This forces users of the image to update.
for datablocks in bpy.data.user_map({current_image}).values():
for datablock in datablocks:
datablock.update_tag()
await pillar.download_file_by_uuid(
file_uuid,
local_path,
meta_path,
filename=filename,
map_type=resolution,
file_loading=file_loading,
file_loaded_sync=file_loaded,
future=self.signalling_future,
)
self.report({"INFO"}, "Image download complete")
# store keymaps here to access after registration
addon_keymaps = []
def image_editor_menu(self, context):
self.layout.operator(
BlenderCloudBrowser.bl_idname,
text="Get image from Blender Cloud",
icon_value=blender.icon("CLOUD"),
)
def hdri_download_panel__image_editor(self, context):
_hdri_download_panel(self, context.edit_image)
def hdri_download_panel__node_editor(self, context):
if context.active_node.type not in {"TEX_ENVIRONMENT", "TEX_IMAGE"}:
return
_hdri_download_panel(self, context.active_node.image)
def _hdri_download_panel(self, current_image):
if not current_image:
return
if "bcloud_node_type" not in current_image:
return
if current_image["bcloud_node_type"] != "hdri":
return
try:
current_variation = current_image["bcloud_file_uuid"]
except KeyError:
log.warning(
"Image %r has a bcloud_node_type but no bcloud_file_uuid property.",
current_image.name,
)
return
row = self.layout.row(align=True).split(factor=0.3)
row.label(text="HDRi", icon_value=blender.icon("CLOUD"))
row.prop(current_image, "hdri_variation", text="")
if current_image.hdri_variation != current_variation:
props = row.operator(
PILLAR_OT_switch_hdri.bl_idname, text="Replace", icon="FILE_REFRESH"
)
props.image_name = current_image.name
props.file_uuid = current_image.hdri_variation
# Storage for variation labels, as the strings in EnumProperty items
# MUST be kept in Python memory.
variation_label_storage = {}
def hdri_variation_choices(self, context):
if context.area.type == "IMAGE_EDITOR":
image = context.edit_image
elif context.area.type == "NODE_EDITOR":
image = context.active_node.image
else:
return []
if "bcloud_node" not in image:
return []
choices = []
for file_doc in image["bcloud_node"]["properties"]["files"]:
label = file_doc["resolution"]
variation_label_storage[label] = label
choices.append((file_doc["file"], label, ""))
return choices
def register():
bpy.utils.register_class(BlenderCloudBrowser)
bpy.utils.register_class(PILLAR_OT_switch_hdri)
bpy.types.IMAGE_MT_image.prepend(image_editor_menu)
bpy.types.IMAGE_PT_image_properties.append(hdri_download_panel__image_editor)
bpy.types.NODE_PT_active_node_properties.append(hdri_download_panel__node_editor)
# HDRi resolution switcher/chooser.
# TODO: when an image is selected, switch this property to its current resolution.
bpy.types.Image.hdri_variation = bpy.props.EnumProperty(
name="HDRi variations",
items=hdri_variation_choices,
description="Select a variation with which to replace this image",
)
# handle the keymap
wm = bpy.context.window_manager
kc = wm.keyconfigs.addon
if not kc:
print("No addon key configuration space found, so no custom hotkeys added.")
return
km = kc.keymaps.new(name="Screen")
kmi = km.keymap_items.new(
"pillar.browser", "A", "PRESS", ctrl=True, shift=True, alt=True
)
addon_keymaps.append((km, kmi))
def unregister():
# handle the keymap
for km, kmi in addon_keymaps:
km.keymap_items.remove(kmi)
addon_keymaps.clear()
if hasattr(bpy.types.Image, "hdri_variation"):
del bpy.types.Image.hdri_variation
bpy.types.IMAGE_MT_image.remove(image_editor_menu)
bpy.types.IMAGE_PT_image_properties.remove(hdri_download_panel__image_editor)
bpy.types.NODE_PT_active_node_properties.remove(hdri_download_panel__node_editor)
bpy.utils.unregister_class(BlenderCloudBrowser)
bpy.utils.unregister_class(PILLAR_OT_switch_hdri)

View File

@ -1,119 +0,0 @@
"""OpenGL drawing code for the texture browser.
Requires Blender 2.80 or newer.
"""
import typing
import bgl
import blf
import bpy
import gpu
from gpu_extras.batch import batch_for_shader
if bpy.app.background:
shader = None
texture_shader = None
else:
shader = gpu.shader.from_builtin("2D_UNIFORM_COLOR")
texture_shader = gpu.shader.from_builtin("2D_IMAGE")
Float2 = typing.Tuple[float, float]
Float3 = typing.Tuple[float, float, float]
Float4 = typing.Tuple[float, float, float, float]
def text(
pos2d: Float2,
display_text: typing.Union[str, typing.List[str]],
rgba: Float4 = (1.0, 1.0, 1.0, 1.0),
fsize=12,
align="L",
):
"""Draw text with the top-left corner at 'pos2d'."""
dpi = bpy.context.preferences.system.dpi
gap = 12
x_pos, y_pos = pos2d
font_id = 0
blf.size(font_id, fsize, dpi)
# Compute the height of one line.
mwidth, mheight = blf.dimensions(font_id, "Tp") # Use high and low letters.
mheight *= 1.5
# Split text into lines.
if isinstance(display_text, str):
mylines = display_text.split("\n")
else:
mylines = display_text
maxwidth = 0
maxheight = len(mylines) * mheight
for idx, line in enumerate(mylines):
text_width, text_height = blf.dimensions(font_id, line)
if align == "C":
newx = x_pos - text_width / 2
elif align == "R":
newx = x_pos - text_width - gap
else:
newx = x_pos
# Draw
blf.position(font_id, newx, y_pos - mheight * idx, 0)
blf.color(font_id, rgba[0], rgba[1], rgba[2], rgba[3])
blf.draw(font_id, " " + line)
# saves max width
if maxwidth < text_width:
maxwidth = text_width
return maxwidth, maxheight
def aabox(v1: Float2, v2: Float2, rgba: Float4):
"""Draw an axis-aligned box."""
coords = [
(v1[0], v1[1]),
(v1[0], v2[1]),
(v2[0], v2[1]),
(v2[0], v1[1]),
]
shader.bind()
shader.uniform_float("color", rgba)
batch = batch_for_shader(shader, "TRI_FAN", {"pos": coords})
batch.draw(shader)
def aabox_with_texture(v1: Float2, v2: Float2):
"""Draw an axis-aligned box with a texture."""
coords = [
(v1[0], v1[1]),
(v1[0], v2[1]),
(v2[0], v2[1]),
(v2[0], v1[1]),
]
texture_shader.bind()
texture_shader.uniform_int("image", 0)
batch = batch_for_shader(
texture_shader,
"TRI_FAN",
{
"pos": coords,
"texCoord": ((0, 0), (0, 1), (1, 1), (1, 0)),
},
)
batch.draw(texture_shader)
def bind_texture(texture: bpy.types.Image):
"""Bind a Blender image to a GL texture slot."""
bgl.glActiveTexture(bgl.GL_TEXTURE0)
bgl.glBindTexture(bgl.GL_TEXTURE_2D, texture.bindcode)
def load_texture(texture: bpy.types.Image) -> int:
"""Load the texture, return OpenGL error code."""
return texture.gl_load()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,209 +0,0 @@
import logging
import os.path
import bpy
import bgl
import pillarsdk
from . import nodes
if bpy.app.version < (2, 80):
from . import draw_27 as draw
else:
from . import draw
library_icons_path = os.path.join(os.path.dirname(__file__), "icons")
ICON_WIDTH = 128
ICON_HEIGHT = 128
class MenuItem:
"""GUI menu item for the 3D View GUI."""
icon_margin_x = 4
icon_margin_y = 4
text_margin_x = 6
text_size = 12
text_size_small = 10
DEFAULT_ICONS = {
"FOLDER": os.path.join(library_icons_path, "folder.png"),
"SPINNER": os.path.join(library_icons_path, "spinner.png"),
"ERROR": os.path.join(library_icons_path, "error.png"),
}
FOLDER_NODE_TYPES = {
"group_texture",
"group_hdri",
nodes.UpNode.NODE_TYPE,
nodes.ProjectNode.NODE_TYPE,
}
SUPPORTED_NODE_TYPES = {"texture", "hdri"}.union(FOLDER_NODE_TYPES)
def __init__(self, node, file_desc, thumb_path: str, label_text):
self.log = logging.getLogger("%s.MenuItem" % __name__)
if node["node_type"] not in self.SUPPORTED_NODE_TYPES:
self.log.info("Invalid node type in node: %s", node)
raise TypeError(
"Node of type %r not supported; supported are %r."
% (node["node_type"], self.SUPPORTED_NODE_TYPES)
)
assert isinstance(node, pillarsdk.Node), "wrong type for node: %r" % type(node)
assert isinstance(node["_id"], str), 'wrong type for node["_id"]: %r' % type(
node["_id"]
)
self.node = node # pillarsdk.Node, contains 'node_type' key to indicate type
self.file_desc = file_desc # pillarsdk.File object, or None if a 'folder' node.
self.label_text = label_text
self.small_text = self._small_text_from_node()
self._thumb_path = ""
self.icon = None
self._is_folder = node["node_type"] in self.FOLDER_NODE_TYPES
self._is_spinning = False
# Determine sorting order.
# by default, sort all the way at the end and folders first.
self._order = 0 if self._is_folder else 10000
if node and node.properties and node.properties.order is not None:
self._order = node.properties.order
self.thumb_path = thumb_path
# Updated when drawing the image
self.x = 0
self.y = 0
self.width = 0
self.height = 0
def _small_text_from_node(self) -> str:
"""Return the components of the texture (i.e. which map types are available)."""
if not self.node:
return ""
try:
node_files = self.node.properties.files
except AttributeError:
# Happens for nodes that don't have .properties.files.
return ""
if not node_files:
return ""
map_types = {f.map_type for f in node_files if f.map_type}
map_types.discard("color") # all textures have colour
if not map_types:
return ""
return ", ".join(sorted(map_types))
def sort_key(self):
"""Key for sorting lists of MenuItems."""
return self._order, self.label_text
@property
def thumb_path(self) -> str:
return self._thumb_path
@thumb_path.setter
def thumb_path(self, new_thumb_path: str):
self._is_spinning = new_thumb_path == "SPINNER"
self._thumb_path = self.DEFAULT_ICONS.get(new_thumb_path, new_thumb_path)
if self._thumb_path:
self.icon = bpy.data.images.load(filepath=self._thumb_path)
else:
self.icon = None
@property
def node_uuid(self) -> str:
return self.node["_id"]
def represents(self, node) -> bool:
"""Returns True iff this MenuItem represents the given node."""
node_uuid = node["_id"]
return self.node_uuid == node_uuid
def update(self, node, file_desc, thumb_path: str, label_text=None):
# We can get updated information about our Node, but a MenuItem should
# always represent one node, and it shouldn't be shared between nodes.
if self.node_uuid != node["_id"]:
raise ValueError(
"Don't change the node ID this MenuItem reflects, "
"just create a new one."
)
self.node = node
self.file_desc = file_desc # pillarsdk.File object, or None if a 'folder' node.
self.thumb_path = thumb_path
if label_text is not None:
self.label_text = label_text
if thumb_path == "ERROR":
self.small_text = "This open is broken"
else:
self.small_text = self._small_text_from_node()
@property
def is_folder(self) -> bool:
return self._is_folder
@property
def is_spinning(self) -> bool:
return self._is_spinning
def update_placement(self, x, y, width, height):
"""Use OpenGL to draw this one menu item."""
self.x = x
self.y = y
self.width = width
self.height = height
def draw(self, highlighted: bool):
bgl.glEnable(bgl.GL_BLEND)
if highlighted:
color = (0.555, 0.555, 0.555, 0.8)
else:
color = (0.447, 0.447, 0.447, 0.8)
draw.aabox((self.x, self.y), (self.x + self.width, self.y + self.height), color)
texture = self.icon
if texture:
err = draw.load_texture(texture)
assert not err, "OpenGL error: %i" % err
# ------ TEXTURE ---------#
if texture:
draw.bind_texture(texture)
bgl.glBlendFunc(bgl.GL_SRC_ALPHA, bgl.GL_ONE_MINUS_SRC_ALPHA)
draw.aabox_with_texture(
(self.x + self.icon_margin_x, self.y),
(self.x + self.icon_margin_x + ICON_WIDTH, self.y + ICON_HEIGHT),
)
bgl.glDisable(bgl.GL_BLEND)
if texture:
texture.gl_free()
# draw some text
text_x = self.x + self.icon_margin_x + ICON_WIDTH + self.text_margin_x
text_y = self.y + ICON_HEIGHT * 0.5 - 0.25 * self.text_size
draw.text((text_x, text_y), self.label_text, fsize=self.text_size)
draw.text(
(text_x, self.y + 0.5 * self.text_size_small),
self.small_text,
fsize=self.text_size_small,
rgba=(1.0, 1.0, 1.0, 0.5),
)
def hits(self, mouse_x: int, mouse_y: int) -> bool:
return (
self.x < mouse_x < self.x + self.width
and self.y < mouse_y < self.y + self.height
)

View File

@ -1,28 +0,0 @@
import pillarsdk
class SpecialFolderNode(pillarsdk.Node):
NODE_TYPE = "SPECIAL"
class UpNode(SpecialFolderNode):
NODE_TYPE = "UP"
def __init__(self):
super().__init__()
self["_id"] = "UP"
self["node_type"] = self.NODE_TYPE
class ProjectNode(SpecialFolderNode):
NODE_TYPE = "PROJECT"
def __init__(self, project):
super().__init__()
assert isinstance(
project, pillarsdk.Project
), "wrong type for project: %r" % type(project)
self.merge(project.to_dict())
self["node_type"] = self.NODE_TYPE

View File

@ -1,109 +0,0 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import json
import pathlib
import typing
from typing import Any, Dict, Optional, Tuple
def sizeof_fmt(num: int, suffix="B") -> str:
"""Returns a human-readable size.
Source: http://stackoverflow.com/a/1094933/875379
"""
for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]:
if abs(num) < 1024:
return "%.1f %s%s" % (num, unit, suffix)
num //= 1024
return "%.1f Yi%s" % (num, suffix)
def find_in_path(path: pathlib.Path, filename: str) -> Optional[pathlib.Path]:
"""Performs a breadth-first search for the filename.
Returns the path that contains the file, or None if not found.
"""
import collections
# Be lenient on our input type.
if isinstance(path, str):
path = pathlib.Path(path)
if not path.exists():
return None
assert path.is_dir()
to_visit = collections.deque([path])
while to_visit:
this_path = to_visit.popleft()
for subpath in this_path.iterdir():
if subpath.is_dir():
to_visit.append(subpath)
continue
if subpath.name == filename:
return subpath
return None
# Mapping from (module name, function name) to the last value returned by that function.
_pyside_cache: Dict[Tuple[str, str], Any] = {}
def pyside_cache(wrapped):
"""Decorator, stores the result of the decorated callable in Python-managed memory.
This is to work around the warning at
https://www.blender.org/api/blender_python_api_master/bpy.props.html#bpy.props.EnumProperty
"""
import functools
@functools.wraps(wrapped)
# We can't use (*args, **kwargs), because EnumProperty explicitly checks
# for the number of fixed positional arguments.
def decorator(self, context):
result = None
try:
result = wrapped(self, context)
return result
finally:
_pyside_cache[wrapped.__module__, wrapped.__name__] = result
return decorator
def redraw(self, context):
if context.area is None:
return
context.area.tag_redraw()
class JSONEncoder(json.JSONEncoder):
"""JSON encoder with support for some Blender types."""
def default(self, o):
if o.__class__.__name__ == "IDPropertyGroup" and hasattr(o, "to_dict"):
return o.to_dict()
return super().default(o)

View File

@ -1,21 +1,3 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
"""External dependencies loader."""
import glob
@ -36,44 +18,24 @@ def load_wheel(module_name, fname_prefix):
try:
module = __import__(module_name)
except ImportError as ex:
log.debug("Unable to import %s directly, will try wheel: %s", module_name, ex)
except ImportError:
pass
else:
log.debug(
"Was able to load %s from %s, no need to load wheel %s",
module_name,
module.__file__,
fname_prefix,
)
log.debug('Was able to load %s from %s, no need to load wheel %s',
module_name, module.__file__, fname_prefix)
return
sys.path.append(wheel_filename(fname_prefix))
module = __import__(module_name)
log.debug("Loaded %s from %s", module_name, module.__file__)
def wheel_filename(fname_prefix: str) -> str:
path_pattern = os.path.join(my_dir, "%s*.whl" % fname_prefix)
path_pattern = os.path.join(my_dir, '%s*.whl' % fname_prefix)
wheels = glob.glob(path_pattern)
if not wheels:
raise RuntimeError("Unable to find wheel at %r" % path_pattern)
raise RuntimeError('Unable to find wheel at %r' % path_pattern)
# If there are multiple wheels that match, load the last-modified one.
# Alphabetical sorting isn't going to cut it since BAT 1.10 was released.
def modtime(filename: str) -> int:
return os.stat(filename).st_mtime
wheels.sort(key=modtime)
return wheels[-1]
sys.path.append(wheels[0])
module = __import__(module_name)
log.debug('Loaded %s from %s', module_name, module.__file__)
def load_wheels():
load_wheel("blender_asset_tracer", "blender_asset_tracer")
load_wheel("lockfile", "lockfile")
load_wheel("cachecontrol", "CacheControl")
load_wheel("pillarsdk", "pillarsdk")
if __name__ == "__main__":
wheel = wheel_filename("blender_asset_tracer")
print(f"Wheel: {wheel}")
load_wheel('lockfile', 'lockfile')
load_wheel('cachecontrol', 'CacheControl')
load_wheel('pillarsdk', 'pillarsdk')

View File

@ -1,8 +0,0 @@
#!/bin/bash
git clean -n -d -X blender_cloud/wheels/
echo "Press [ENTER] to actually delete those files."
read dummy
git clean -f -d -X blender_cloud/wheels/

View File

@ -1,13 +0,0 @@
#!/bin/bash -e
FULLNAME="$(python3 setup.py --fullname)"
echo "Press [ENTER] to deploy $FULLNAME to /shared"
read dummy
./clear_wheels.sh
python3 setup.py wheels bdist
DISTDIR=$(pwd)/dist
cd /shared/software/addons
rm -vf blender_cloud/wheels/*.whl # remove obsolete wheel files
unzip -o $DISTDIR/$FULLNAME.addon.zip

View File

@ -1,8 +0,0 @@
-r requirements.txt
# Primary requirements
pytest==3.0.3
# Secondary requirements
py==1.4.31

View File

@ -1,17 +1,15 @@
# Primary requirements:
-e git+https://github.com/sybrenstuvel/cachecontrol.git@sybren-filecache-delete-crash-fix#egg=CacheControl
CacheControl==0.11.6
lockfile==0.12.2
pillarsdk==1.8.0
pillarsdk==1.1.2
wheel==0.29.0
blender-asset-tracer==1.11
# Secondary requirements:
asn1crypto==0.24.0
cffi==1.11.2
cryptography==2.1.4
idna==2.6
cffi==1.6.0
cryptography==1.3.1
idna==2.1
pyasn1==0.1.9
pycparser==2.18
pyOpenSSL==17.5.0
pycparser==2.14
pyOpenSSL==16.0.0
requests==2.10.0
six==1.11.0
six==1.10.0

223
setup.py
View File

@ -1,46 +1,19 @@
#!/usr/bin/env python3
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
#!/usr/bin/env python
import glob
import sys
import shutil
import subprocess
import re
import pathlib
import zipfile
from distutils import log
from distutils.core import Command
from distutils.command.bdist import bdist
from distutils.command.install import install, INSTALL_SCHEMES
from distutils.command.install import install
from distutils.command.install_egg_info import install_egg_info
from setuptools import setup, find_packages
requirement_re = re.compile("[><=]+")
sys.dont_write_bytecode = True
# Download wheels from pypi. The specific versions are taken from requirements.txt
wheels = [
"lockfile",
"pillarsdk",
"blender-asset-tracer",
]
requirement_re = re.compile('[><=]+')
def set_default_path(var, default):
@ -57,38 +30,35 @@ class BuildWheels(Command):
description = "builds/downloads the dependencies as wheel files"
user_options = [
("wheels-path=", None, "wheel file installation path"),
("deps-path=", None, "path in which dependencies are built"),
("cachecontrol-path=", None, "subdir of deps-path containing CacheControl"),
('wheels-path=', None, "wheel file installation path"),
('deps-path=', None, "path in which dependencies are built"),
('cachecontrol-path=', None, "subdir of deps-path containing CacheControl"),
]
def initialize_options(self):
self.wheels_path = None # path that will contain the installed wheels.
self.deps_path = None # path in which dependencies are built.
self.cachecontrol_path = None # subdir of deps_path containing CacheControl
self.bat_path = None # subdir of deps_path containing Blender-Asset-Tracer
def finalize_options(self):
self.my_path = pathlib.Path(__file__).resolve().parent
package_path = self.my_path / self.distribution.get_name()
self.wheels_path = set_default_path(self.wheels_path, package_path / "wheels")
self.deps_path = set_default_path(self.deps_path, self.my_path / "build/deps")
self.cachecontrol_path = set_default_path(
self.cachecontrol_path, self.deps_path / "cachecontrol"
)
self.bat_path = self.deps_path / "bat"
self.wheels_path = set_default_path(self.wheels_path, package_path / 'wheels')
self.deps_path = set_default_path(self.deps_path, self.my_path / 'build/deps')
self.cachecontrol_path = set_default_path(self.cachecontrol_path,
self.deps_path / 'cachecontrol')
def run(self):
log.info("Storing wheels in %s", self.wheels_path)
log.info('Storing wheels in %s', self.wheels_path)
# Parse the requirements.txt file
requirements = {}
with open(str(self.my_path / "requirements.txt")) as reqfile:
with open(str(self.my_path / 'requirements.txt')) as reqfile:
for line in reqfile.readlines():
line = line.strip()
if not line or line.startswith("#"):
if not line or line.startswith('#'):
# comments are lines that start with # only
continue
@ -99,46 +69,39 @@ class BuildWheels(Command):
# log.info(' - %s = %s / %s', package, line, line_req[-1])
self.wheels_path.mkdir(parents=True, exist_ok=True)
for package in wheels:
pattern = package.replace("-", "_") + "*.whl"
if list(self.wheels_path.glob(pattern)):
continue
self.download_wheel(requirements[package])
# Download lockfile, as there is a suitable wheel on pypi.
if not list(self.wheels_path.glob('lockfile*.whl')):
log.info('Downloading lockfile wheel')
self.download_wheel(requirements['lockfile'])
# Download Pillar Python SDK from pypi.
if not list(self.wheels_path.glob('pillarsdk*.whl')):
log.info('Downloading Pillar Python SDK wheel')
self.download_wheel(requirements['pillarsdk'])
# Build CacheControl.
if not list(self.wheels_path.glob("CacheControl*.whl")):
log.info("Building CacheControl in %s", self.cachecontrol_path)
# self.git_clone(self.cachecontrol_path,
# 'https://github.com/ionrock/cachecontrol.git',
# 'v%s' % requirements['CacheControl'][1])
# FIXME: we need my clone until pull request #125 has been merged & released
self.git_clone(
self.cachecontrol_path,
"https://github.com/sybrenstuvel/cachecontrol.git",
"sybren-filecache-delete-crash-fix",
)
if not list(self.wheels_path.glob('CacheControl*.whl')):
log.info('Building CacheControl in %s', self.cachecontrol_path)
self.git_clone(self.cachecontrol_path,
'https://github.com/ionrock/cachecontrol.git',
'v%s' % requirements['CacheControl'][1])
self.build_copy_wheel(self.cachecontrol_path)
# Ensure that the wheels are added to the data files.
self.distribution.data_files.append(
("blender_cloud/wheels", (str(p) for p in self.wheels_path.glob("*.whl")))
('blender_cloud/wheels', (str(p) for p in self.wheels_path.glob('*.whl')))
)
def download_wheel(self, requirement):
"""Downloads a wheel from PyPI and saves it in self.wheels_path."""
subprocess.check_call(
[
sys.executable,
"-m",
"pip",
"download",
"--no-deps",
"--dest",
str(self.wheels_path),
requirement[0],
]
)
subprocess.check_call([
'pip', 'download',
'--no-deps',
'--dest', str(self.wheels_path),
requirement[0]
])
def git_clone(self, workdir: pathlib.Path, git_url: str, checkout: str = None):
if workdir.exists():
@ -147,25 +110,24 @@ class BuildWheels(Command):
workdir.mkdir(parents=True)
subprocess.check_call(
["git", "clone", git_url, str(workdir)], cwd=str(workdir.parent)
)
subprocess.check_call(['git', 'clone', git_url, str(workdir)],
cwd=str(workdir.parent))
if checkout:
subprocess.check_call(["git", "checkout", checkout], cwd=str(workdir))
subprocess.check_call(['git', 'checkout', checkout],
cwd=str(workdir))
def build_copy_wheel(self, package_path: pathlib.Path):
# Make sure no wheels exist yet, so that we know which one to copy later.
to_remove = list((package_path / "dist").glob("*.whl"))
to_remove = list((package_path / 'dist').glob('*.whl'))
for fname in to_remove:
fname.unlink()
subprocess.check_call(
[sys.executable, "setup.py", "bdist_wheel"], cwd=str(package_path)
)
subprocess.check_call([sys.executable, 'setup.py', 'bdist_wheel'],
cwd=str(package_path))
wheel = next((package_path / "dist").glob("*.whl"))
log.info("copying %s to %s", wheel, self.wheels_path)
wheel = next((package_path / 'dist').glob('*.whl'))
log.info('copying %s to %s', wheel, self.wheels_path)
shutil.copy(str(wheel), str(self.wheels_path))
@ -175,58 +137,22 @@ class BlenderAddonBdist(bdist):
def initialize_options(self):
super().initialize_options()
self.formats = ["zip"]
self.plat_name = "addon" # use this instead of 'linux-x86_64' or similar.
self.fix_local_prefix()
def fix_local_prefix(self):
"""Place data files in blender_cloud instead of local/blender_cloud."""
for key in INSTALL_SCHEMES:
if "data" not in INSTALL_SCHEMES[key]:
continue
INSTALL_SCHEMES[key]["data"] = "$base"
self.formats = ['zip']
self.plat_name = 'addon' # use this instead of 'linux-x86_64' or similar.
def run(self):
self.run_command("wheels")
self.run_command('wheels')
super().run()
# noinspection PyAttributeOutsideInit
class BlenderAddonFdist(BlenderAddonBdist):
"""Ensures that 'python setup.py fdist' creates a plain folder structure."""
user_options = [
("dest-path=", None, "addon installation path"),
]
def initialize_options(self):
super().initialize_options()
self.dest_path = None # path that will contain the addon
def run(self):
super().run()
# dist_files is a list of tuples ('bdist', 'any', 'filepath')
filepath = self.distribution.dist_files[0][2]
# if dest_path is not specified use the filename as the dest_path (minus the .zip)
assert filepath.endswith(".zip")
target_folder = self.dest_path or filepath[:-4]
print("Unzipping the package on {}.".format(target_folder))
with zipfile.ZipFile(filepath, "r") as zip_ref:
zip_ref.extractall(target_folder)
# noinspection PyAttributeOutsideInit
class BlenderAddonInstall(install):
"""Ensures the module is placed at the root of the zip file."""
def initialize_options(self):
super().initialize_options()
self.prefix = ""
self.install_lib = ""
self.prefix = ''
self.install_lib = ''
class AvoidEggInfo(install_egg_info):
@ -241,38 +167,29 @@ class AvoidEggInfo(install_egg_info):
setup(
cmdclass={
"bdist": BlenderAddonBdist,
"fdist": BlenderAddonFdist,
"install": BlenderAddonInstall,
"install_egg_info": AvoidEggInfo,
"wheels": BuildWheels,
},
name="blender_cloud",
description="The Blender Cloud addon allows browsing the Blender Cloud from Blender.",
version="1.25",
author="Sybren A. Stüvel",
author_email="sybren@stuvel.eu",
packages=find_packages("."),
data_files=[
("blender_cloud", ["README.md", "README-flamenco.md", "CHANGELOG.md"]),
("blender_cloud/icons", glob.glob("blender_cloud/icons/*")),
(
"blender_cloud/texture_browser/icons",
glob.glob("blender_cloud/texture_browser/icons/*"),
),
],
cmdclass={'bdist': BlenderAddonBdist,
'install': BlenderAddonInstall,
'install_egg_info': AvoidEggInfo,
'wheels': BuildWheels},
name='blender_cloud',
description='The Blender Cloud addon allows browsing the Blender Cloud from Blender.',
version='1.0.1',
author='Sybren A. Stüvel',
author_email='sybren@stuvel.eu',
packages=find_packages('.'),
data_files=[('blender_cloud', ['README.md']),
('blender_cloud/icons', glob.glob('blender_cloud/icons/*'))],
scripts=[],
url="https://developer.blender.org/diffusion/BCA/",
license="GNU General Public License v2 or later (GPLv2+)",
platforms="",
url='https://developer.blender.org/diffusion/BCA/',
license='GNU General Public License v2 or later (GPLv2+)',
platforms='',
classifiers=[
"Intended Audience :: End Users/Desktop",
"Operating System :: OS Independent",
"Environment :: Plugins",
"License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)",
"Programming Language :: Python",
"Programming Language :: Python :: 3.5",
'Intended Audience :: End Users/Desktop',
'Operating System :: OS Independent',
'Environment :: Plugins',
'License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)',
'Programming Language :: Python',
'Programming Language :: Python :: 3.5',
],
zip_safe=False,
)

View File

@ -1,120 +0,0 @@
"""Unittests for blender_cloud.utils.
This unittest requires bpy to be importable, so build Blender as a module and install it
into your virtualenv. See https://stuvel.eu/files/bconf2016/#/10 for notes how.
"""
import datetime
import pathlib
import unittest.mock
import pillarsdk.utils
from blender_cloud.flamenco import sdk
class PathReplacementTest(unittest.TestCase):
def setUp(self):
self.test_manager = sdk.Manager(
{
"_created": datetime.datetime(
2017, 5, 31, 15, 12, 32, tzinfo=pillarsdk.utils.utc
),
"_etag": "c39942ee4bcc4658adcc21e4bcdfb0ae",
"_id": "592edd609837732a2a272c62",
"_updated": datetime.datetime(
2017, 6, 8, 14, 51, 3, tzinfo=pillarsdk.utils.utc
),
"description": 'Manager formerly known as "testman"',
"job_types": {"sleep": {"vars": {}}},
"name": '<script>alert("this is a manager")</script>',
"owner": "592edd609837732a2a272c63",
"path_replacement": {
"job_storage": {
"darwin": "/Volume/shared",
"linux": "/shared",
"windows": "s:/",
},
"render": {
"darwin": "/Volume/render/",
"linux": "/render/",
"windows": "r:/",
},
"longrender": {
"darwin": "/Volume/render/long",
"linux": "/render/long",
"windows": "r:/long",
},
},
"projects": ["58cbdd5698377322d95eb55e"],
"service_account": "592edd609837732a2a272c60",
"stats": {"nr_of_workers": 3},
"url": "http://192.168.3.101:8083/",
"user_groups": ["58cbdd5698377322d95eb55f"],
"variables": {
"blender": {
"darwin": "/opt/myblenderbuild/blender",
"linux": "/home/sybren/workspace/build_linux/bin/blender "
"--enable-new-depsgraph --factory-startup",
"windows": "c:/temp/blender.exe",
}
},
}
)
def test_linux(self):
# (expected result, input)
test_paths = [
("/doesnotexistreally", "/doesnotexistreally"),
("{render}/agent327/scenes/A_01_03_B", "/render/agent327/scenes/A_01_03_B"),
("{job_storage}/render/agent327/scenes", "/shared/render/agent327/scenes"),
("{longrender}/agent327/scenes", "/render/long/agent327/scenes"),
]
self._do_test(test_paths, "linux", pathlib.PurePosixPath)
def test_windows(self):
# (expected result, input)
test_paths = [
("c:/doesnotexistreally", "c:/doesnotexistreally"),
("c:/some/path", r"c:\some\path"),
("{render}/agent327/scenes/A_01_03_B", r"R:\agent327\scenes\A_01_03_B"),
("{render}/agent327/scenes/A_01_03_B", r"r:\agent327\scenes\A_01_03_B"),
("{render}/agent327/scenes/A_01_03_B", r"r:/agent327/scenes/A_01_03_B"),
("{job_storage}/render/agent327/scenes", "s:/render/agent327/scenes"),
("{longrender}/agent327/scenes", "r:/long/agent327/scenes"),
]
self._do_test(test_paths, "windows", pathlib.PureWindowsPath)
def test_darwin(self):
# (expected result, input)
test_paths = [
("/Volume/doesnotexistreally", "/Volume/doesnotexistreally"),
(
"{render}/agent327/scenes/A_01_03_B",
r"/Volume/render/agent327/scenes/A_01_03_B",
),
(
"{job_storage}/render/agent327/scenes",
"/Volume/shared/render/agent327/scenes",
),
("{longrender}/agent327/scenes", "/Volume/render/long/agent327/scenes"),
]
self._do_test(test_paths, "darwin", pathlib.PurePosixPath)
def _do_test(self, test_paths, platform, pathclass):
self.test_manager.PurePlatformPath = pathclass
def mocked_system():
return platform
with unittest.mock.patch("platform.system", mocked_system):
for expected_result, input_path in test_paths:
as_path_instance = pathclass(input_path)
self.assertEqual(
expected_result,
self.test_manager.replace_path(as_path_instance),
"for input %r on platform %s" % (as_path_instance, platform),
)

View File

@ -1,25 +0,0 @@
"""Unittests for blender_cloud.utils."""
import pathlib
import unittest
from blender_cloud import utils
class FindInPathTest(unittest.TestCase):
def test_nonexistant_path(self):
path = pathlib.Path("/doesnotexistreally")
self.assertFalse(path.exists())
self.assertIsNone(utils.find_in_path(path, "jemoeder.blend"))
def test_really_breadth_first(self):
"""A depth-first test might find dir_a1/dir_a2/dir_a3/find_me.txt first."""
path = pathlib.Path(__file__).parent / "test_really_breadth_first"
found = utils.find_in_path(path, "find_me.txt")
self.assertEqual(path / "dir_b1" / "dir_b2" / "find_me.txt", found)
def test_nonexistant_file(self):
path = pathlib.Path(__file__).parent / "test_really_breadth_first"
found = utils.find_in_path(path, "do_not_find_me.txt")
self.assertEqual(None, found)

View File

@ -1,22 +0,0 @@
#!/bin/bash
VERSION="${1/version-}"
if [ -z "$VERSION" ]; then
echo "Usage: $0 new-version" >&2
exit 1
fi
BL_INFO_VER=$(echo "$VERSION" | sed 's/\./, /g')
sed "s/version=\"[^\"]*\"/version=\"$VERSION\"/" -i setup.py
sed "s/\"version\": ([^)]*)/\"version\": ($BL_INFO_VER)/" -i blender_cloud/__init__.py
git diff
echo
echo "Don't forget to commit and tag:"
echo git commit -m \'Bumped version to $VERSION\' setup.py blender_cloud/__init__.py
echo git tag -a version-$VERSION -m \'Tagged version $VERSION\'
echo
echo "To build a distribution ZIP:"
echo python3 setup.py bdist