Compare commits

..

133 Commits

Author SHA1 Message Date
843667e612 Bumped version to 1.6.0 2017-02-14 10:22:35 +01:00
cf3f7234eb Default to frame chunk size of 1 (instead of 10) 2017-02-14 10:22:06 +01:00
4647175a7e Turn off "use overwrite" and "use placeholder" for Flamenco blend files.
If you want to re-render frames, delete them first, then reschedule
the render task.
2017-02-14 10:21:50 +01:00
33da5195f3 Fixed bugs when blendfile is outside the project directory 2017-02-01 14:00:46 +01:00
3814fb2683 Bumped version to 1.5.999999 2017-02-01 09:57:49 +01:00
15484a65cd Tweaked output dir to not end in '.flamenco' 2017-01-31 18:29:10 +01:00
d9e2b36204 A bit broader exception handling 2017-01-31 18:29:00 +01:00
cc690ec8c9 Always use EXR for progressive rendering 2017-01-31 18:28:47 +01:00
0422070d55 Flamenco: use 6 #-signs, Flamenco Server expects this 2017-01-31 18:28:35 +01:00
8cefb4fb07 Flamenco: Take "Square samples" into account when computing nr of samples 2017-01-31 18:28:14 +01:00
23549fa676 Added support for blender-render-progressive jobs 2017-01-30 10:38:35 +01:00
cb73030e6a Bumped version to 1.5.99999 2017-01-24 17:22:04 +01:00
fbf02c3625 Captialising labels/captions 2017-01-24 15:15:08 +01:00
95699aca36 Open webbrowser after submitting a Flamenco job
This can be disabled in the add-on preferences.
2017-01-24 15:14:43 +01:00
60018cd78c Using explicit status EnumProperty to show Flamenco status
By Andy's request, I've removed the window_manager.progress_xxx stuff (so
no longer hijacking the mouse cursor) and instead show the Flamenco status
in the Flamenco Render panel.
2017-01-24 14:55:39 +01:00
5f73837d3c Don't write to artist's blend file, but to temporary file.
This file is saved to '{blendfile}.flamenco.blend', which is used to BAM-
pack and create the Flamenco job. After that, the file is removed from
the local filesystem again.
2017-01-24 14:39:49 +01:00
9272e22129 Bumped version to 1.5.9999 to indicate 1.6.0-beta2 2017-01-19 15:44:17 +01:00
e14a0aa53c Warn in the Flamenco panel about the "Overwrite" checkbox. 2017-01-19 15:06:03 +01:00
51cf097c8f Removed logging when file is outside project path 2017-01-19 15:04:12 +01:00
4608204f1d Prevent error when current file is outside Flamenco project path 2017-01-18 16:45:54 +01:00
3f95249196 Include README-flamenco.md in the distribution ZIP 2017-01-18 14:39:10 +01:00
9df016da09 Bumped version to 1.5.999 2017-01-18 14:37:09 +01:00
64e29e695b Flamenco: determine render output path using add-on prefs and filename. 2017-01-18 14:31:25 +01:00
d5f285a381 self._log -> self.log 2017-01-18 12:50:50 +01:00
e39429272d Single quotes intead of double 2017-01-18 10:53:16 +01:00
bdb00eeaaa Flamenco: show crude progress when BAM-packing & creating Flamenco job 2017-01-18 09:35:23 +01:00
3ef2ca0c07 Added scene properties for Flamenco render job type & priority 2017-01-18 09:34:45 +01:00
35d4f85010 Added BAM-packing, requires version of Blender that includes BAM 1.1.1 2017-01-17 17:30:37 +01:00
8151b952b9 Construct ProactorEventLoop on win32 for subprocess support 2017-01-17 16:02:34 +01:00
2d2585b8d7 Fixed bug in flamenco.unregister 2017-01-13 17:57:57 +01:00
65204db228 Bumped version to 1.5.99 to indicate 1.6-beta0 2017-01-13 17:47:37 +01:00
570b1d4bfe Initial Flamenco support.
Lots to do:

- Doesn't call BAM yet to copy files onto the job storage folder (even
  though you can configure that folder).
- Uses the same project as Attract, so you have to select it in an
  unintuitive location. Also, you can only start Flamenco jobs on a project
  that is Attract-enabled (and not necessarily Flamenco-enabled).
2017-01-13 17:24:37 +01:00
68b046c714 Bumped version to 1.5.2 2017-01-06 17:01:58 +01:00
cb20d6ee03 Fixed icon, the MOVE_UP_VEC icon was removed
This happened in Blender commit rBb2159b94bcd6c4e30c47b9970601e6f2ca2a0750
2017-01-06 17:01:06 +01:00
Dalai Felinto
645bdd950f Attract: prevent ui/console errors when no strip exists 2016-12-06 22:00:58 +01:00
Dalai Felinto
74a5830dae setup fdist option for files distribution (unzipped files)
This is the equivalent of `python setup.py bdist` and unzipping the outputted file. If no --dest-path is specified it dumps the files in the same folder as the .zip.

It is used for symlinking of the latest addon in production computers without having to deal with .zip files.

Review, design, lessons, patient orientations by Sybren Stüvel
2016-11-11 16:15:24 +01:00
da4d4df5fb Bumped version to 1.5.1 2016-11-11 09:28:10 +01:00
9c3098cc0d Attract: Added some poll methods 2016-11-11 09:26:52 +01:00
98beaf7fb7 Unlinking a single shot copies the shot ID to the clipboard.
This allows you to easily unlink one strip, and relink another. This cannot
be done for multiple shots simultaneously.
2016-11-11 09:23:39 +01:00
3364371ac6 Use '{nr of shots} Shots' instead of 'Selected Shots'
This is a bit shorter, and more concrete.
2016-11-11 09:22:59 +01:00
2723b07fa2 Nicer button layout for unlink & delete
This makes 'delete' harder to hit accidentally.
2016-11-11 09:17:25 +01:00
c2a037ca89 Added button to copy a shot ID to the clipboard 2016-11-11 09:15:04 +01:00
d3451d4de3 Icon tweaks 2016-11-11 09:14:51 +01:00
5094977614 Attract: "delete shots" now works on all selected Attract strips 2016-11-11 09:06:59 +01:00
Dalai Felinto
a11a55be22 Implement attract "Trim End" 2016-11-10 18:49:13 +01:00
68d2fc8e42 Fixed scene update post handler disappearing. 2016-11-08 15:11:27 +01:00
2de4a8e87c Removed beta warning 2016-11-08 14:09:33 +01:00
39b2bacdcc Bumped version to 1.5.0 2016-11-08 14:08:15 +01:00
a1416f99dd Added script to bump versions in all the right places.
Must be called with major.minor.micro revision number (so 3 components).

Signed-off-by: Sybren A. Stüvel <sybren@stuvel.eu>
2016-11-08 14:08:03 +01:00
0fa7d60028 Keep Attract & Image Share prefs enabled when sync error is shown. 2016-11-08 10:14:26 +01:00
c1b6480f9a Texture browser: Also draw a dark background for INITIALIZING state 2016-11-08 10:14:26 +01:00
56353d4177 More captialisation of button labels. 2016-11-07 13:39:33 +01:00
469a9318af Also allow meta strips to be considered "shots" in Attract. 2016-11-07 13:39:14 +01:00
e265081131 Change wiki URL to HTTPS 2016-11-07 11:23:30 +01:00
115eea82c6 Prevent errors when there is no sequencer in the current scene 2016-11-04 17:47:27 +01:00
900068a6f5 Slight improvements to Attract shot delete operator 2016-11-04 17:47:13 +01:00
c8229500d1 Bugfix 2016-11-04 16:42:26 +01:00
65ff9da428 Attract: Draw conflicts more subtly 2016-11-04 16:12:13 +01:00
fcba8a2e0f Attract: Compute strip conflicts in scene update handler 2016-11-04 16:11:59 +01:00
7ef5e522f8 Attract thumbs: only use middle frame when current frame not on shot
This now also applies when rendering multiple shot thumbnails.
2016-11-04 15:45:23 +01:00
ae570e5907 Allow undeletion of shots by relinking to the edit. 2016-11-04 13:44:12 +01:00
16b90d2ea8 Bumped pillarsdk version to 1.6.1 2016-11-04 12:38:30 +01:00
875c92ee9d Bumped version 1.4.999 2016-11-04 12:34:04 +01:00
cbfc75a89c Attract: added operator to send all shots to Attract at once. 2016-11-04 12:32:21 +01:00
54e676b36f Only send 'unlink' cmd to Attract if ObjectID no longer used in edit.
Previously it would always send 'unlink', even when a duplicate strip was
removed.
2016-11-04 12:05:21 +01:00
c94b0f5f2d Slightly nicer wording of delete warning. 2016-11-04 12:04:53 +01:00
a58bfe9a76 Be more explicit in strip ObjectID conflicts.
Shows more warnings in the UI, and the conflict status is now saved on
an RNA property atc_object_id_conflict.
2016-11-04 12:04:38 +01:00
d332e1e50a Attract: Draw conflicts (two strips linked to the same shot) in red outline 2016-11-04 11:36:40 +01:00
Dalai Felinto
dd66d5ce93 UI elements should be capitalized in Blender 2016-11-04 10:20:54 +01:00
965b02cec4 Attract: Send relink/unlink shots as PATCH 2016-11-03 18:44:31 +01:00
fd67675c12 Formatting 2016-11-03 17:37:06 +01:00
06a661126b Support rendering for selection of shots.
Also chooses middle frame of shot when rendering a single shot AND the
current frame isn't on that shot.
2016-11-03 17:24:44 +01:00
191b150280 Removed redundant tempfile 2016-11-03 16:33:33 +01:00
feb62ddae0 Attract: render thumbnail button
Still a bit rough, needs GUI response to show upload is happening.
2016-11-03 14:47:54 +01:00
603159f0d1 Improved labels for some Attract buttons 2016-11-03 12:54:33 +01:00
d2ae3f9cb7 Improved pyside_cache decorator
This one should work properly when multiple properties use the same
callback function.
2016-11-03 12:53:03 +01:00
079f8ff4c3 When relinking shot, get node ID from clipboard 2016-10-18 14:31:56 +02:00
c97859ef33 Unified three 'submit to Attract' operators. 2016-10-18 14:22:37 +02:00
f1ebea8948 Added "Open shot in browser" button 2016-10-18 12:26:46 +02:00
1b82977c6e Always unregister module, even when Sequence property doesn't want to be deleted 2016-10-18 12:25:05 +02:00
a7307bf7b5 Separate URLs for web and API calls 2016-10-18 12:24:43 +02:00
11fd12e125 Fixed copy/paste error 2016-10-18 12:23:36 +02:00
54dccb20ba Prevent caching issue when refreshing shot info from Attract 2016-10-18 12:23:27 +02:00
61a8db3f96 Added workaround for EnumProperty string limitation
See https://www.blender.org/api/blender_python_api_master/bpy.props.html#bpy.props.EnumProperty
2016-10-18 11:20:06 +02:00
0b2f0a3ec1 Open in new Blender: switch to the scene that rendered the mkv 2016-10-13 10:06:15 +02:00
5117ec7cde Open in new Blender: pass all --enable-xxx CLI options to sub-blender. 2016-10-13 10:05:58 +02:00
74f61fa83a Open in new Blender: also switch to correct scene 2016-10-11 12:51:20 +02:00
f73671c4f0 Added "Open in new Blender" button for movie strips with metadata.
The metadata requires the following fields:

"BLENDER:BLEND_FILE" (to open the file)
"BLENDER:START_FRAME" and "BLENDER:END_FRAME" (just to display)
2016-10-11 11:17:24 +02:00
6f970a41e5 Added utils.find_in_path + unittest 2016-10-11 10:52:27 +02:00
ccedb7cbb1 Fix operator descriptions ending in '.'
By default, blender will take the operator class description comment as the description for the UI tooltip, which should not have a trailing dot.
I duplicated the descriptions into an explicit bl_description to have a clear distinction between user UI and coder comment, but simply removing the dot from the comment would also work
2016-10-04 23:17:50 +02:00
abcd8b0168 Added description for PillarCredentialsUpdate operator. 2016-10-04 16:44:15 +02:00
534a5a6ac4 Attract project refresh: Show report when credential checking fails 2016-10-04 16:42:13 +02:00
6b5faa423e Bumped pillarsdk requirement to 1.6.0 2016-10-04 16:27:28 +02:00
232e8f6167 Bumped version to 1.4.99 (because Blender doesn't do beta versions)
The bl_info['version'] value is just a tuple of numbers, so there is no
room for beta version info. I did add a warning, though.
2016-10-04 16:15:40 +02:00
af0dee0c9d Make attract.strip_unlink unlink all selected strips, and not just the active 2016-09-30 13:58:49 +02:00
baac86f59b New shots should start with 'todo' status.
In a later version this default status should either be set server-side
and returned in the POST response, or be taken from the project definition
by this add-on.
2016-09-30 13:58:31 +02:00
19d54b7fd6 Don't filter available projects on is_private=True. 2016-09-29 18:54:50 +02:00
0067157251 Dim status line for strips with atc_is_synced == False 2016-09-27 16:53:43 +02:00
4c84fbf339 Fixed strip drawing.
It didn't consider meta-strips, we look at that now. Also the line is
thinner and uses the same colours as the web interface to indicate strip
status.
2016-09-27 16:51:46 +02:00
6a5c392b5b Lots of Attract tweaks 2016-09-27 16:26:46 +02:00
cbaccaed49 Different node type name 2016-09-27 15:55:39 +02:00
cfc53e007c Removed more atc_cut_in/out 2016-09-27 15:51:24 +02:00
2768f0a59f Added refreshing data that is determined by web interface. 2016-09-27 15:43:52 +02:00
b6c7ec1546 Removed unused Attract properties 2016-09-27 15:43:35 +02:00
417b6e80f5 Working on Attract integration 2016-09-23 17:45:06 +02:00
90259297ca Use project UUID from new property in preferences 2016-09-23 14:06:36 +02:00
3d9f4e893a Store attract project in preferences (instead of windowmanager)
This actually saves the available projects, allowing a refresh when needed.
2016-09-23 14:06:36 +02:00
4be497ed27 Added project selector for Attract. 2016-09-23 14:06:36 +02:00
28fe6e8f96 pillar.call → pillar.sync_call 2016-09-23 14:06:36 +02:00
22e4f2dc5e Some cosmetic changes 2016-09-23 14:06:36 +02:00
537dcf846a No need to manually compute frame_final_start 2016-09-23 14:06:36 +02:00
8ca4159fe8 Sync shot notes & description 2016-09-23 14:06:36 +02:00
d7bf001ffe Show cut-out frame nr as read-only property. 2016-09-23 14:06:36 +02:00
6ea15d2bfe No need to manually keep track of index. 2016-09-23 14:06:36 +02:00
6fda496652 Attract sequence strip buttons are working. 2016-09-23 14:06:36 +02:00
8dab01138e More work on attract integration 2016-09-23 14:06:36 +02:00
3da76ddb24 Added proper error messages for when the project needs Attract setup.
When the 'shot' node type doesn't exist, we now show an error message
about this (instead of causing an IndexError).
2016-09-23 14:06:36 +02:00
c57da7ab2b WIP: integration of the Attract addon into the Blender Cloud adddon. 2016-09-23 14:06:36 +02:00
63b976cb44 Allow texture browser usage when the blend file is dirty.
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.

Fixes T49203.
2016-09-06 12:30:48 +02:00
73a62da8da Fixed some issues with new db_user-returning credential check. 2016-08-30 16:57:21 +02:00
2c70ceb489 Solved issue T48992 2016-08-30 16:33:59 +02:00
38ccb54b50 Switch to new API URL. 2016-08-30 16:33:51 +02:00
1df113ca01 check_credentials() now returns the entire user, not just the ID. 2016-08-26 17:43:40 +02:00
887a9cc697 Allow async operators to automatically quit when they raise an exception.
Just set the class property `stop_upon_exception=True`.
2016-08-26 17:43:40 +02:00
143456ae1d Made AsyncModalOperatorMixin.invoke() start self.async_execute(context).
This was already common practice in all subclasses, and has now been
moved into the mixin.
2016-08-26 17:43:40 +02:00
f41ea8c5a3 Ignore __pycache__ dirs 2016-08-26 16:16:21 +02:00
7d90a92e24 Bumped version to 1.4.3 2016-08-23 14:41:33 +02:00
2388f800dc Fix T49080: Blender Cloud add-on error uploading screenshot
The screenshot filename contained colons, which isn't allowed on Windows.
2016-08-23 14:40:41 +02:00
38a3bcba71 Bumped version to 1.4.2, to re-distribute with B'ID addon 1.1.0 2016-08-04 14:39:00 +02:00
2cf400a74c Remove trailing slash from pillar_endpoint for BlenderID Addon 1.1.0
BlenderID Addon 1.1.0 uses endpoint URLs differently, so now directory-
like URLs have to end in a slash.
2016-08-04 12:46:42 +02:00
54ebb0bf5d Removed support: OFFICIAL, as that's reserved for Blender-bundled addons. 2016-08-04 11:21:59 +02:00
9e84d2a416 Only ignore blend files at the root dir 2016-07-29 11:05:51 +02:00
772e6b0b1b bundle.sh: warn when an addon can't be found. 2016-07-29 11:02:31 +02:00
23 changed files with 2520 additions and 69 deletions

4
.gitignore vendored
View File

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

57
README-flamenco.md Normal file
View File

@@ -0,0 +1,57 @@
# 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

@@ -22,6 +22,9 @@ cd $(dirname $(readlink -f $0))
BCLOUD=$(ls ../dist/blender_cloud-*.addon.zip | tail -n 1) BCLOUD=$(ls ../dist/blender_cloud-*.addon.zip | tail -n 1)
BID=$(ls ../../../blender-id-addon/dist/blender_id-*.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 . cp -va $BCLOUD $BID .
BUNDLE=$(basename $BCLOUD) BUNDLE=$(basename $BCLOUD)

View File

@@ -20,16 +20,15 @@
bl_info = { bl_info = {
'name': 'Blender Cloud', 'name': 'Blender Cloud',
'author': 'Sybren A. Stüvel and Francesco Siddi', "author": "Sybren A. Stüvel, Francesco Siddi, Inês Almeida, Antony Riakiotakis",
'version': (1, 4, 1), 'version': (1, 6, 0),
'blender': (2, 77, 0), 'blender': (2, 77, 0),
'location': 'Addon Preferences panel, and Ctrl+Shift+Alt+A anywhere for texture browser', '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 ' 'description': 'Texture library browser and Blender Sync. Requires the Blender ID addon '
'and Blender 2.77a or newer.', 'and Blender 2.77a or newer.',
'wiki_url': 'http://wiki.blender.org/index.php/Extensions:2.6/Py/' 'wiki_url': 'https://wiki.blender.org/index.php/Extensions:2.6/Py/'
'Scripts/System/BlenderCloud', 'Scripts/System/BlenderCloud',
'category': 'System', 'category': 'System',
'support': 'OFFICIAL'
} }
import logging import logging
@@ -79,17 +78,21 @@ def register():
texture_browser = reload_mod('texture_browser') texture_browser = reload_mod('texture_browser')
settings_sync = reload_mod('settings_sync') settings_sync = reload_mod('settings_sync')
image_sharing = reload_mod('image_sharing') image_sharing = reload_mod('image_sharing')
attract = reload_mod('attract')
flamenco = reload_mod('flamenco')
else: else:
from . import (blender, texture_browser, async_loop, settings_sync, blendfile, home_project, from . import (blender, texture_browser, async_loop, settings_sync, blendfile, home_project,
image_sharing) image_sharing, attract, flamenco)
async_loop.setup_asyncio_executor() async_loop.setup_asyncio_executor()
async_loop.register() async_loop.register()
flamenco.register()
texture_browser.register() texture_browser.register()
blender.register() blender.register()
settings_sync.register() settings_sync.register()
image_sharing.register() image_sharing.register()
attract.register()
def _monkey_patch_requests(): def _monkey_patch_requests():
@@ -110,10 +113,13 @@ def _monkey_patch_requests():
def unregister(): def unregister():
from . import blender, texture_browser, async_loop, settings_sync, image_sharing from . import (blender, texture_browser, async_loop, settings_sync, image_sharing, attract,
flamenco)
image_sharing.unregister() image_sharing.unregister()
attract.unregister()
settings_sync.unregister() settings_sync.unregister()
blender.unregister() blender.unregister()
texture_browser.unregister() texture_browser.unregister()
async_loop.unregister() async_loop.unregister()
flamenco.unregister()

View File

@@ -39,9 +39,18 @@ def setup_asyncio_executor():
calls that could be performed in parallel are queued, and thus we can calls that could be performed in parallel are queued, and thus we can
reliably cancel them. reliably cancel them.
""" """
import sys
executor = concurrent.futures.ThreadPoolExecutor() executor = concurrent.futures.ThreadPoolExecutor()
loop = asyncio.get_event_loop()
if sys.platform == 'win32':
# 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
loop = asyncio.ProactorEventLoop()
asyncio.set_event_loop(loop)
else:
loop = asyncio.get_event_loop()
loop.set_default_executor(executor) loop.set_default_executor(executor)
# loop.set_debug(True) # loop.set_debug(True)
@@ -178,12 +187,28 @@ class AsyncModalOperatorMixin:
log = logging.getLogger('%s.AsyncModalOperatorMixin' % __name__) log = logging.getLogger('%s.AsyncModalOperatorMixin' % __name__)
_state = 'INITIALIZING' _state = 'INITIALIZING'
stop_upon_exception = False
def invoke(self, context, event): def invoke(self, context, event):
context.window_manager.modal_handler_add(self) context.window_manager.modal_handler_add(self)
self.timer = context.window_manager.event_timer_add(1 / 15, context.window) self.timer = context.window_manager.event_timer_add(1 / 15, context.window)
self.log.info('Starting')
self._new_async_task(self.async_execute(context))
return {'RUNNING_MODAL'} 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): def execute(self, context):
return self.invoke(context, None) return self.invoke(context, None)
@@ -195,6 +220,11 @@ class AsyncModalOperatorMixin:
if ex is not None: if ex is not None:
self._state = 'EXCEPTION' self._state = 'EXCEPTION'
self.log.error('Exception while running task: %s', ex) self.log.error('Exception while running task: %s', ex)
if self.stop_upon_exception:
self.quit()
self._finish(context)
return {'FINISHED'}
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
if self._state == 'QUIT': if self._state == 'QUIT':

View File

@@ -0,0 +1,960 @@
# ##### 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>
# Old info, kept here for reference, so that we can merge wiki pages,
# descriptions, etc.
#
# bl_info = {
# "name": "Attract",
# "author": "Francesco Siddi, Inês Almeida, Antony Riakiotakis",
# "version": (0, 2, 0),
# "blender": (2, 76, 0),
# "location": "Video Sequence Editor",
# "description":
# "Blender integration with the Attract task tracking service"
# ". *requires the Blender ID add-on",
# "wiki_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/"
# "Scripts/Workflow/Attract",
# "category": "Workflow",
# "support": "TESTING"
# }
import contextlib
import functools
import logging
if "bpy" in locals():
import importlib
draw = importlib.reload(draw)
pillar = importlib.reload(pillar)
async_loop = importlib.reload(async_loop)
else:
from . import draw
from .. import pillar, async_loop
import bpy
import pillarsdk
from pillarsdk.nodes import Node
from pillarsdk.projects import Project
from pillarsdk import exceptions as sdk_exceptions
from bpy.types import Operator, Panel, AddonPreferences
log = logging.getLogger(__name__)
def active_strip(context):
try:
return context.scene.sequence_editor.active_strip
except AttributeError:
return None
def selected_shots(context):
"""Generator, yields selected strips if they are Attract shots."""
selected_sequences = context.selected_sequences
if selected_sequences is None:
return
for strip in selected_sequences:
atc_object_id = getattr(strip, 'atc_object_id')
if not atc_object_id:
continue
yield strip
def all_shots(context):
"""Generator, yields all strips if they are Attract shots."""
sequence_editor = context.scene.sequence_editor
if sequence_editor is None:
# we should throw an exception, but at least this change prevents an error
return []
for strip in context.scene.sequence_editor.sequences_all:
atc_object_id = getattr(strip, 'atc_object_id')
if not atc_object_id:
continue
yield strip
def shown_strips(context):
"""Returns the strips from the current meta-strip-stack, or top-level strips.
What is returned depends on what the user is currently editing.
"""
if context.scene.sequence_editor.meta_stack:
return context.scene.sequence_editor.meta_stack[-1].sequences
return context.scene.sequence_editor.sequences
def remove_atc_props(strip):
"""Resets the attract custom properties assigned to a VSE strip"""
strip.atc_name = ""
strip.atc_description = ""
strip.atc_object_id = ""
strip.atc_is_synced = False
def shot_id_use(strips):
"""Returns a mapping from shot Object ID to a list of strips that use it."""
import collections
# Count the number of uses per Object ID, so that we can highlight double use.
ids_in_use = collections.defaultdict(list)
for strip in strips:
if not getattr(strip, 'atc_is_synced', False):
continue
ids_in_use[strip.atc_object_id].append(strip)
return ids_in_use
def compute_strip_conflicts(scene):
"""Sets the strip property atc_object_id_conflict for each strip."""
if not scene or not scene.sequence_editor or not scene.sequence_editor.sequences_all:
return
tag_redraw = False
ids_in_use = shot_id_use(scene.sequence_editor.sequences_all)
for strips in ids_in_use.values():
is_conflict = len(strips) > 1
for strip in strips:
if strip.atc_object_id_conflict != is_conflict:
tag_redraw = True
strip.atc_object_id_conflict = is_conflict
if tag_redraw:
draw.tag_redraw_all_sequencer_editors()
return ids_in_use
@bpy.app.handlers.persistent
def scene_update_post_handler(scene):
compute_strip_conflicts(scene)
class ToolsPanel(Panel):
bl_label = 'Attract'
bl_space_type = 'SEQUENCE_EDITOR'
bl_region_type = 'UI'
def draw_header(self, context):
strip = active_strip(context)
if strip and strip.atc_object_id:
self.layout.prop(strip, 'atc_is_synced', text='')
def draw(self, context):
strip = active_strip(context)
layout = self.layout
strip_types = {'MOVIE', 'IMAGE', 'META'}
selshots = list(selected_shots(context))
if strip and strip.type in strip_types and strip.atc_object_id:
if len(selshots) > 1:
noun = '%i Shots' % len(selshots)
else:
noun = 'This Shot'
if strip.atc_object_id_conflict:
warnbox = layout.box()
warnbox.alert = True
warnbox.label('Warning: This shot is linked to multiple sequencer strips.',
icon='ERROR')
layout.prop(strip, 'atc_name', text='Name')
layout.prop(strip, 'atc_status', text='Status')
# Create a special sub-layout for read-only properties.
ro_sub = layout.column(align=True)
ro_sub.enabled = False
ro_sub.prop(strip, 'atc_description', text='Description')
ro_sub.prop(strip, 'atc_notes', text='Notes')
if strip.atc_is_synced:
sub = layout.column(align=True)
row = sub.row(align=True)
if bpy.ops.attract.submit_selected.poll():
row.operator('attract.submit_selected',
text='Submit %s' % noun,
icon='TRIA_UP')
else:
row.operator(ATTRACT_OT_submit_all.bl_idname)
row.operator(AttractShotFetchUpdate.bl_idname,
text='', icon='FILE_REFRESH')
row.operator(ATTRACT_OT_shot_open_in_browser.bl_idname,
text='', icon='WORLD')
row.operator(ATTRACT_OT_copy_id_to_clipboard.bl_idname,
text='', icon='COPYDOWN')
sub.operator(ATTRACT_OT_make_shot_thumbnail.bl_idname,
text='Render Thumbnail for %s' % noun,
icon='RENDER_STILL')
# Group more dangerous operations.
dangerous_sub = layout.split(0.6, align=True)
dangerous_sub.operator('attract.strip_unlink',
text='Unlink %s' % noun,
icon='PANEL_CLOSE')
dangerous_sub.operator(AttractShotDelete.bl_idname,
text='Delete %s' % noun,
icon='CANCEL')
elif context.selected_sequences:
if len(context.selected_sequences) > 1:
noun = 'Selected Strips'
else:
noun = 'This Strip'
layout.operator(AttractShotSubmitSelected.bl_idname,
text='Submit %s as New Shot' % noun)
layout.operator('attract.shot_relink')
else:
layout.operator(ATTRACT_OT_submit_all.bl_idname)
class AttractOperatorMixin:
"""Mix-in class for all Attract operators."""
def _project_needs_setup_error(self):
self.report({'ERROR'}, 'Your Blender Cloud project is not set up for Attract.')
return {'CANCELLED'}
@functools.lru_cache()
def find_project(self, project_uuid: str) -> Project:
"""Finds a single project.
Caches the result in memory to prevent more than one call to Pillar.
"""
from .. import pillar
project = pillar.sync_call(Project.find_one, {'where': {'_id': project_uuid}})
return project
def find_node_type(self, node_type_name: str) -> dict:
from .. import pillar, blender
prefs = blender.preferences()
project = self.find_project(prefs.attract_project.project)
# FIXME: Eve doesn't seem to handle the $elemMatch projection properly,
# even though it works fine in MongoDB itself. As a result, we have to
# search for the node type.
node_type_list = project['node_types']
node_type = next((nt for nt in node_type_list if nt['name'] == node_type_name), None)
if not node_type:
return self._project_needs_setup_error()
return node_type
def submit_new_strip(self, strip):
from .. import pillar, blender
# Define the shot properties
user_uuid = pillar.pillar_user_uuid()
if not user_uuid:
self.report({'ERROR'}, 'Your Blender Cloud user ID is not known, '
'update your credentials.')
return {'CANCELLED'}
prop = {'name': strip.name,
'description': '',
'properties': {'status': 'todo',
'notes': '',
'used_in_edit': True,
'trim_start_in_frames': strip.frame_offset_start,
'trim_end_in_frames': strip.frame_offset_end,
'duration_in_edit_in_frames': strip.frame_final_duration,
'cut_in_timeline_in_frames': strip.frame_final_start},
'order': 0,
'node_type': 'attract_shot',
'project': blender.preferences().attract_project.project,
'user': user_uuid}
# Create a Node item with the attract API
node = Node(prop)
post = pillar.sync_call(node.create)
# Populate the strip with the freshly generated ObjectID and info
if not post:
self.report({'ERROR'}, 'Error creating node! Check the console for now.')
return {'CANCELLED'}
strip.atc_object_id = node['_id']
strip.atc_is_synced = True
strip.atc_name = node['name']
strip.atc_description = node['description']
strip.atc_notes = node['properties']['notes']
strip.atc_status = node['properties']['status']
draw.tag_redraw_all_sequencer_editors()
def submit_update(self, strip):
import pillarsdk
from .. import pillar
patch = {
'op': 'from-blender',
'$set': {
'name': strip.atc_name,
'properties.trim_start_in_frames': strip.frame_offset_start,
'properties.trim_end_in_frames': strip.frame_offset_end,
'properties.duration_in_edit_in_frames': strip.frame_final_duration,
'properties.cut_in_timeline_in_frames': strip.frame_final_start,
'properties.status': strip.atc_status,
'properties.used_in_edit': True,
}
}
node = pillarsdk.Node({'_id': strip.atc_object_id})
result = pillar.sync_call(node.patch, patch)
log.info('PATCH result: %s', result)
def relink(self, strip, atc_object_id, *, refresh=False):
from .. import pillar
# The node may have been deleted, so we need to send a 'relink' before we try
# to fetch the node itself.
node = Node({'_id': atc_object_id})
pillar.sync_call(node.patch, {'op': 'relink'})
try:
node = pillar.sync_call(Node.find, atc_object_id, caching=False)
except (sdk_exceptions.ResourceNotFound, sdk_exceptions.MethodNotAllowed):
verb = 'refresh' if refresh else 'relink'
self.report({'ERROR'}, 'Shot %r not found on the Attract server, unable to %s.'
% (atc_object_id, verb))
strip.atc_is_synced = False
return {'CANCELLED'}
strip.atc_is_synced = True
if not refresh:
strip.atc_name = node.name
strip.atc_object_id = node['_id']
# We do NOT set the position/cuts of the shot, that always has to come from Blender.
strip.atc_status = node.properties.status
strip.atc_notes = node.properties.notes or ''
strip.atc_description = node.description or ''
draw.tag_redraw_all_sequencer_editors()
class AttractShotFetchUpdate(AttractOperatorMixin, Operator):
bl_idname = "attract.shot_fetch_update"
bl_label = "Fetch Update From Attract"
bl_description = 'Update status, description & notes from Attract'
@classmethod
def poll(cls, context):
return any(selected_shots(context))
def execute(self, context):
for strip in selected_shots(context):
status = self.relink(strip, strip.atc_object_id, refresh=True)
# We don't abort when one strip fails. All selected shots should be
# refreshed, even if one can't be found (for example).
if not isinstance(status, set):
self.report({'INFO'}, "Shot {0} refreshed".format(strip.atc_name))
return {'FINISHED'}
class AttractShotRelink(AttractShotFetchUpdate):
bl_idname = "attract.shot_relink"
bl_label = "Relink With Attract"
strip_atc_object_id = bpy.props.StringProperty()
@classmethod
def poll(cls, context):
strip = active_strip(context)
return strip is not None and not getattr(strip, 'atc_object_id', None)
def execute(self, context):
strip = active_strip(context)
status = self.relink(strip, self.strip_atc_object_id)
if isinstance(status, set):
return status
strip.atc_object_id = self.strip_atc_object_id
self.report({'INFO'}, "Shot {0} relinked".format(strip.atc_name))
return {'FINISHED'}
def invoke(self, context, event):
maybe_id = context.window_manager.clipboard
if len(maybe_id) == 24:
try:
int(maybe_id, 16)
except ValueError:
pass
else:
self.strip_atc_object_id = maybe_id
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
layout = self.layout
col = layout.column()
col.prop(self, 'strip_atc_object_id', text='Shot ID')
class ATTRACT_OT_shot_open_in_browser(AttractOperatorMixin, Operator):
bl_idname = 'attract.shot_open_in_browser'
bl_label = 'Open in Browser'
bl_description = 'Opens a webbrowser to show the shot on Attract'
@classmethod
def poll(cls, context):
return bool(context.selected_sequences and active_strip(context))
def execute(self, context):
from ..blender import PILLAR_WEB_SERVER_URL
import webbrowser
import urllib.parse
strip = active_strip(context)
url = urllib.parse.urljoin(PILLAR_WEB_SERVER_URL,
'nodes/%s/redir' % strip.atc_object_id)
webbrowser.open_new_tab(url)
self.report({'INFO'}, 'Opened a browser at %s' % url)
return {'FINISHED'}
class AttractShotDelete(AttractOperatorMixin, Operator):
bl_idname = 'attract.shot_delete'
bl_label = 'Delete Shot'
bl_description = 'Remove this shot from Attract'
confirm = bpy.props.BoolProperty(name='confirm')
@classmethod
def poll(cls, context):
return bool(context.selected_sequences)
def execute(self, context):
from .. import pillar
if not self.confirm:
self.report({'WARNING'}, 'Delete aborted.')
return {'CANCELLED'}
removed = kept = 0
for strip in selected_shots(context):
node = pillar.sync_call(Node.find, strip.atc_object_id)
if not pillar.sync_call(node.delete):
self.report({'ERROR'}, 'Unable to delete shot %s on Attract.' % strip.atc_name)
kept += 1
continue
remove_atc_props(strip)
removed += 1
if kept:
self.report({'ERROR'}, 'Removed %i shots, but was unable to remove %i' %
(removed, kept))
else:
self.report({'INFO'}, 'Removed all %i shots from Attract' % removed)
draw.tag_redraw_all_sequencer_editors()
return {'FINISHED'}
def invoke(self, context, event):
self.confirm = False
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
layout = self.layout
col = layout.column()
selshots = list(selected_shots(context))
if len(selshots) > 1:
noun = '%i shots' % len(selshots)
else:
noun = 'this shot'
col.prop(self, 'confirm', text="I hereby confirm: delete %s from The Edit." % noun)
class AttractStripUnlink(AttractOperatorMixin, Operator):
bl_idname = 'attract.strip_unlink'
bl_label = 'Unlink Shot From This Strip'
bl_description = 'Remove Attract props from the selected strip(s)'
@classmethod
def poll(cls, context):
return bool(context.selected_sequences)
def execute(self, context):
unlinked_ids = set()
# First remove the Attract properties from the strips.
for strip in context.selected_sequences:
atc_object_id = getattr(strip, 'atc_object_id')
remove_atc_props(strip)
if atc_object_id:
unlinked_ids.add(atc_object_id)
# For all Object IDs that are no longer in use in the edit, let Attract know.
# This should be done with care, as the shot could have been attached to multiple
# strips.
id_to_shots = compute_strip_conflicts(context.scene)
for oid in unlinked_ids:
if len(id_to_shots[oid]):
# Still in use
continue
node = Node({'_id': oid})
pillar.sync_call(node.patch, {'op': 'unlink'})
if len(unlinked_ids) == 1:
shot_id = unlinked_ids.pop()
context.window_manager.clipboard = shot_id
self.report({'INFO'}, 'Copied unlinked shot ID %s to clipboard' % shot_id)
else:
self.report({'INFO'}, '%i shots have been marked as Unused.' % len(unlinked_ids))
draw.tag_redraw_all_sequencer_editors()
return {'FINISHED'}
class AttractShotSubmitSelected(AttractOperatorMixin, Operator):
bl_idname = 'attract.submit_selected'
bl_label = 'Submit All Selected'
bl_description = 'Submits all selected strips to Attract'
@classmethod
def poll(cls, context):
return bool(context.selected_sequences)
def execute(self, context):
# Check that the project is set up for Attract.
maybe_error = self.find_node_type('attract_shot')
if isinstance(maybe_error, set):
return maybe_error
for strip in context.selected_sequences:
status = self.submit(strip)
if isinstance(status, set):
return status
self.report({'INFO'}, 'All selected strips sent to Attract.')
return {'FINISHED'}
def submit(self, strip):
atc_object_id = getattr(strip, 'atc_object_id', None)
# Submit as new?
if not atc_object_id:
return self.submit_new_strip(strip)
# Or just save to Attract.
return self.submit_update(strip)
class ATTRACT_OT_submit_all(AttractOperatorMixin, Operator):
bl_idname = 'attract.submit_all'
bl_label = 'Submit All Shots to Attract'
bl_description = 'Updates Attract with the current state of the edit'
def execute(self, context):
# Check that the project is set up for Attract.
maybe_error = self.find_node_type('attract_shot')
if isinstance(maybe_error, set):
return maybe_error
for strip in all_shots(context):
status = self.submit_update(strip)
if isinstance(status, set):
return status
self.report({'INFO'}, 'All strips re-sent to Attract.')
return {'FINISHED'}
class ATTRACT_OT_open_meta_blendfile(AttractOperatorMixin, Operator):
bl_idname = 'attract.open_meta_blendfile'
bl_label = 'Open Blendfile'
bl_description = 'Open Blendfile from movie strip metadata'
@classmethod
def poll(cls, context):
return bool(any(cls.filename_from_metadata(s) for s in context.selected_sequences))
@staticmethod
def filename_from_metadata(strip):
"""Returns the blendfile name from the strip metadata, or None."""
# Metadata is a dict like:
# meta = {'END_FRAME': '88',
# 'BLEND_FILE': 'metadata-test.blend',
# 'SCENE': 'SüperSčene',
# 'FRAME_STEP': '1',
# 'START_FRAME': '32'}
meta = strip.get('metadata', None)
if not meta:
return None
return meta.get('BLEND_FILE', None) or None
def execute(self, context):
for strip in context.selected_sequences:
meta = strip.get('metadata', None)
if not meta:
continue
fname = meta.get('BLEND_FILE', None)
if not fname: continue
scene = meta.get('SCENE', None)
self.open_in_new_blender(fname, scene)
return {'FINISHED'}
def open_in_new_blender(self, fname, scene):
"""
:type fname: str
:type scene: str
"""
import subprocess
import sys
cmd = [
bpy.app.binary_path,
str(fname),
]
cmd[1:1] = [v for v in sys.argv if v.startswith('--enable-')]
if scene:
cmd.extend(['--python-expr',
'import bpy; bpy.context.screen.scene = bpy.data.scenes["%s"]' % scene])
cmd.extend(['--scene', scene])
subprocess.Popen(cmd)
class ATTRACT_OT_make_shot_thumbnail(AttractOperatorMixin,
async_loop.AsyncModalOperatorMixin,
Operator):
bl_idname = 'attract.make_shot_thumbnail'
bl_label = 'Render Shot Thumbnail'
bl_description = 'Renders the current frame, and uploads it as thumbnail for the shot'
stop_upon_exception = True
@classmethod
def poll(cls, context):
return bool(context.selected_sequences)
@contextlib.contextmanager
def thumbnail_render_settings(self, context, thumbnail_width=512):
# Remember current settings so we can restore them later.
orig_res_x = context.scene.render.resolution_x
orig_res_y = context.scene.render.resolution_y
orig_percentage = context.scene.render.resolution_percentage
orig_file_format = context.scene.render.image_settings.file_format
orig_quality = context.scene.render.image_settings.quality
try:
# Update the render size to something thumbnaily.
factor = orig_res_y / orig_res_x
context.scene.render.resolution_x = thumbnail_width
context.scene.render.resolution_y = round(thumbnail_width * factor)
context.scene.render.resolution_percentage = 100
context.scene.render.image_settings.file_format = 'JPEG'
context.scene.render.image_settings.quality = 85
yield
finally:
# Return the render settings to normal.
context.scene.render.resolution_x = orig_res_x
context.scene.render.resolution_y = orig_res_y
context.scene.render.resolution_percentage = orig_percentage
context.scene.render.image_settings.file_format = orig_file_format
context.scene.render.image_settings.quality = orig_quality
@contextlib.contextmanager
def temporary_current_frame(self, context):
"""Allows the context to set the scene current frame, restores it on exit.
Yields the initial current frame, so it can be used for reference in the context.
"""
current_frame = context.scene.frame_current
try:
yield current_frame
finally:
context.scene.frame_current = current_frame
async def async_execute(self, context):
nr_of_strips = len(context.selected_sequences)
do_multishot = nr_of_strips > 1
with self.temporary_current_frame(context) as original_curframe:
# The multishot and singleshot branches do pretty much the same thing,
# but report differently to the user.
if do_multishot:
context.window_manager.progress_begin(0, nr_of_strips)
try:
self.report({'INFO'}, 'Rendering thumbnails for %i selected shots.' %
nr_of_strips)
strips = sorted(context.selected_sequences, key=self.by_frame)
for idx, strip in enumerate(strips):
context.window_manager.progress_update(idx)
# Pick the middle frame, except for the strip the original current frame
# marker was over.
if not self.strip_contains(strip, original_curframe):
self.set_middle_frame(context, strip)
else:
context.scene.frame_set(original_curframe)
await self.thumbnail_strip(context, strip)
if self._state == 'QUIT':
return
context.window_manager.progress_update(nr_of_strips)
finally:
context.window_manager.progress_end()
else:
strip = active_strip(context)
if not self.strip_contains(strip, original_curframe):
self.report({'WARNING'}, 'Rendering middle frame as thumbnail for active shot.')
self.set_middle_frame(context, strip)
else:
self.report({'INFO'}, 'Rendering current frame as thumbnail for active shot.')
context.window_manager.progress_begin(0, 1)
context.window_manager.progress_update(0)
try:
await self.thumbnail_strip(context, strip)
finally:
context.window_manager.progress_update(1)
context.window_manager.progress_end()
if self._state == 'QUIT':
return
self.report({'INFO'}, 'Thumbnail uploaded to Attract')
self.quit()
@staticmethod
def strip_contains(strip, framenr: int) -> bool:
"""Returns True iff the strip covers the given frame number"""
return strip.frame_final_start <= framenr <= strip.frame_final_end
@staticmethod
def set_middle_frame(context, strip):
"""Sets the current frame to the middle frame of the strip."""
middle = round((strip.frame_final_start + strip.frame_final_end) / 2)
context.scene.frame_set(middle)
@staticmethod
def by_frame(sequence_strip) -> int:
"""Returns the start frame number of the sequence strip.
This can be used for sorting strips by time.
"""
return sequence_strip.frame_final_start
async def thumbnail_strip(self, context, strip):
atc_object_id = getattr(strip, 'atc_object_id', None)
if not atc_object_id:
self.report({'ERROR'}, 'Strip %s not set up for Attract' % strip.name)
self.quit()
return
with self.thumbnail_render_settings(context):
bpy.ops.render.render()
file_id = await self.upload_via_tempdir(bpy.data.images['Render Result'],
'attract_shot_thumbnail.jpg')
if file_id is None:
self.quit()
return
# Update the shot to include this file as the picture.
node = pillarsdk.Node({'_id': atc_object_id})
await pillar.pillar_call(
node.patch,
{
'op': 'from-blender',
'$set': {
'picture': file_id,
}
})
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.
"""
import tempfile
import os.path
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_file(self, filename: str, fileobj=None):
"""Uploads a file to the cloud, attached to the image sharing node.
Returns the node.
"""
from .. import blender
prefs = blender.preferences()
project = self.find_project(prefs.attract_project.project)
self.log.info('Uploading file %s', filename)
resp = await pillar.pillar_call(
pillarsdk.File.upload_to_project,
project['_id'],
'image/jpeg',
filename,
fileobj=fileobj)
self.log.debug('Returned data: %s', resp)
try:
file_id = resp['file_id']
except KeyError:
self.log.error('Upload did not succeed, response: %s', resp)
self.report({'ERROR'}, 'Unable to upload thumbnail to Attract: %s' % resp)
return None
self.log.info('Created file %s', file_id)
self.report({'INFO'}, 'File succesfully uploaded to the cloud!')
return file_id
class ATTRACT_OT_copy_id_to_clipboard(AttractOperatorMixin, Operator):
bl_idname = 'attract.copy_id_to_clipboard'
bl_label = 'Copy shot ID to clipboard'
@classmethod
def poll(cls, context):
return bool(context.selected_sequences and active_strip(context))
def execute(self, context):
strip = active_strip(context)
context.window_manager.clipboard = strip.atc_object_id
self.report({'INFO'}, 'Shot ID %s copied to clipboard' % strip.atc_object_id)
return {'FINISHED'}
def draw_strip_movie_meta(self, context):
strip = active_strip(context)
if not strip:
return
meta = strip.get('metadata', None)
if not meta:
return None
box = self.layout.column(align=True)
row = box.row(align=True)
fname = meta.get('BLEND_FILE', None) or None
if fname:
row.label('Original Blendfile: %s' % fname)
row.operator(ATTRACT_OT_open_meta_blendfile.bl_idname,
text='', icon='FILE_BLEND')
sfra = meta.get('START_FRAME', '?')
efra = meta.get('END_FRAME', '?')
box.label('Original Frame Range: %s-%s' % (sfra, efra))
def register():
bpy.types.Sequence.atc_is_synced = bpy.props.BoolProperty(name="Is Synced")
bpy.types.Sequence.atc_object_id = bpy.props.StringProperty(name="Attract Object ID")
bpy.types.Sequence.atc_object_id_conflict = bpy.props.BoolProperty(
name='Object ID Conflict',
description='Attract Object ID used multiple times',
default=False)
bpy.types.Sequence.atc_name = bpy.props.StringProperty(name="Shot Name")
bpy.types.Sequence.atc_description = bpy.props.StringProperty(name="Shot Description")
bpy.types.Sequence.atc_notes = bpy.props.StringProperty(name="Shot Notes")
# TODO: get this from the project's node type definition.
bpy.types.Sequence.atc_status = bpy.props.EnumProperty(
items=[
('on_hold', 'On Hold', 'The shot is on hold'),
('todo', 'Todo', 'Waiting'),
('in_progress', 'In Progress', 'The show has been assigned'),
('review', 'Review', ''),
('final', 'Final', ''),
],
name="Status")
bpy.types.Sequence.atc_order = bpy.props.IntProperty(name="Order")
bpy.types.SEQUENCER_PT_edit.append(draw_strip_movie_meta)
bpy.utils.register_class(ToolsPanel)
bpy.utils.register_class(AttractShotRelink)
bpy.utils.register_class(AttractShotDelete)
bpy.utils.register_class(AttractStripUnlink)
bpy.utils.register_class(AttractShotFetchUpdate)
bpy.utils.register_class(AttractShotSubmitSelected)
bpy.utils.register_class(ATTRACT_OT_submit_all)
bpy.utils.register_class(ATTRACT_OT_open_meta_blendfile)
bpy.utils.register_class(ATTRACT_OT_shot_open_in_browser)
bpy.utils.register_class(ATTRACT_OT_make_shot_thumbnail)
bpy.utils.register_class(ATTRACT_OT_copy_id_to_clipboard)
bpy.app.handlers.scene_update_post.append(scene_update_post_handler)
draw.callback_enable()
def unregister():
draw.callback_disable()
bpy.app.handlers.scene_update_post.remove(scene_update_post_handler)
bpy.utils.unregister_module(__name__)
del bpy.types.Sequence.atc_is_synced
del bpy.types.Sequence.atc_object_id
del bpy.types.Sequence.atc_object_id_conflict
del bpy.types.Sequence.atc_name
del bpy.types.Sequence.atc_description
del bpy.types.Sequence.atc_notes
del bpy.types.Sequence.atc_status
del bpy.types.Sequence.atc_order

View File

@@ -0,0 +1,177 @@
# ##### 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 bpy
import logging
import collections
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) # RGB tuple
def get_strip_rectf(strip):
# 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 draw_underline_in_strip(strip_coords, pixel_size_x, color):
from bgl import glColor4f, glRectf, glEnable, glDisable, GL_BLEND
import bgl
context = bpy.context
# Strip coords
s_x1, s_y1, s_x2, s_y2 = strip_coords
# be careful not to draw over the current frame line
cf_x = context.scene.frame_current_final
bgl.glPushAttrib(bgl.GL_COLOR_BUFFER_BIT | bgl.GL_LINE_BIT)
glColor4f(*color)
glEnable(GL_BLEND)
bgl.glLineWidth(2)
bgl.glBegin(bgl.GL_LINES)
bgl.glVertex2f(s_x1, s_y1)
if s_x1 < cf_x < s_x2:
# Bad luck, the line passes our strip
bgl.glVertex2f(cf_x - pixel_size_x, s_y1)
bgl.glVertex2f(cf_x + pixel_size_x, s_y1)
bgl.glVertex2f(s_x2, s_y1)
bgl.glEnd()
bgl.glPopAttrib()
def draw_strip_conflict(strip_coords, pixel_size_x):
"""Draws conflicting states between strips."""
import bgl
s_x1, s_y1, s_x2, s_y2 = strip_coords
bgl.glPushAttrib(bgl.GL_COLOR_BUFFER_BIT | bgl.GL_LINE_BIT)
# Always draw the full rectangle, the conflict should be resolved and thus stand out.
bgl.glColor3f(*CONFLICT_COLOUR)
bgl.glLineWidth(2)
bgl.glBegin(bgl.GL_LINE_LOOP)
bgl.glVertex2f(s_x1, s_y1)
bgl.glVertex2f(s_x2, s_y1)
bgl.glVertex2f(s_x2, s_y2)
bgl.glVertex2f(s_x1, s_y2)
bgl.glEnd()
bgl.glPopAttrib()
def draw_callback_px():
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)
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
draw_underline_in_strip(strip_coords, pixel_size_x, color + (alpha,))
if strip.atc_is_synced and strip.atc_object_id_conflict:
draw_strip_conflict(strip_coords, pixel_size_x)
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
cb_handle[:] = bpy.types.SpaceSequenceEditor.draw_handler_add(
draw_callback_px, (), 'WINDOW', 'POST_VIEW'),
tag_redraw_all_sequencer_editors()
def callback_disable():
if not cb_handle:
return
bpy.types.SpaceSequenceEditor.draw_handler_remove(cb_handle[0], 'WINDOW')
tag_redraw_all_sequencer_editors()

View File

@@ -26,13 +26,15 @@ import os.path
import bpy import bpy
from bpy.types import AddonPreferences, Operator, WindowManager, Scene, PropertyGroup from bpy.types import AddonPreferences, Operator, WindowManager, Scene, PropertyGroup
from bpy.props import StringProperty, EnumProperty, PointerProperty, BoolProperty from bpy.props import StringProperty, EnumProperty, PointerProperty, BoolProperty, IntProperty
import rna_prop_ui import rna_prop_ui
from . import pillar from . import pillar, async_loop, flamenco
from .utils import pyside_cache, redraw
PILLAR_SERVER_URL = 'https://cloudapi.blender.org/' PILLAR_WEB_SERVER_URL = 'https://cloud.blender.org/'
# PILLAR_SERVER_URL = 'http://localhost:5000/' # PILLAR_WEB_SERVER_URL = 'http://pillar-web:5001/'
PILLAR_SERVER_URL = '%sapi/' % PILLAR_WEB_SERVER_URL
ADDON_NAME = 'blender_cloud' ADDON_NAME = 'blender_cloud'
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -40,11 +42,10 @@ log = logging.getLogger(__name__)
icons = None icons = None
def redraw(self, context): @pyside_cache('version')
context.area.tag_redraw()
def blender_syncable_versions(self, context): def blender_syncable_versions(self, context):
"""Returns the list of items used by SyncStatusProperties.version EnumProperty."""
bss = context.window_manager.blender_sync_status bss = context.window_manager.blender_sync_status
versions = bss.available_blender_versions versions = bss.available_blender_versions
if not versions: if not versions:
@@ -104,6 +105,43 @@ class SyncStatusProperties(PropertyGroup):
self['available_blender_versions'] = new_versions self['available_blender_versions'] = new_versions
@pyside_cache('project')
def bcloud_available_projects(self, context):
"""Returns the list of items used by BlenderCloudProjectGroup.project EnumProperty."""
attr_proj = preferences().attract_project
projs = attr_proj.available_projects
if not projs:
return [('', 'No projects available in your Blender Cloud', '')]
return [(p['_id'], p['name'], '') for p in projs]
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')
# 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
class BlenderCloudPreferences(AddonPreferences): class BlenderCloudPreferences(AddonPreferences):
bl_idname = ADDON_NAME bl_idname = ADDON_NAME
@@ -117,12 +155,53 @@ class BlenderCloudPreferences(AddonPreferences):
) )
local_texture_dir = StringProperty( local_texture_dir = StringProperty(
name='Default Blender Cloud texture storage directory', name='Default Blender Cloud Texture Storage Directory',
subtype='DIR_PATH', subtype='DIR_PATH',
default='//textures') default='//textures')
open_browser_after_share = BoolProperty( open_browser_after_share = BoolProperty(
name='Open browser after sharing file', name='Open Browser after Sharing File',
description='When enabled, Blender will open a webbrowser',
default=True
)
# TODO: store local path with the Attract project, so that people
# can switch projects and the local path switches with it.
attract_project = PointerProperty(type=BlenderCloudProjectGroup)
attract_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='//../')
flamenco_manager = PointerProperty(type=flamenco.FlamencoManagerGroup)
# TODO: before making Flamenco public, change the defaults to something less Institute-specific.
# NOTE: The assumption is that the workers can also find the files in the same path.
# This assumption is true for the Blender Institute.
flamenco_job_file_path = StringProperty(
name='Job File Path',
description='Path where to store job files, should be accesible for Workers too',
subtype='DIR_PATH',
default='/render/_flamenco/storage')
# TODO: before making Flamenco public, change the defaults to something less Institute-specific.
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='/render/_flamenco/output')
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,
)
flamenco_open_browser_after_submit = BoolProperty(
name='Open Browser after Submitting Job',
description='When enabled, Blender will open a webbrowser', description='When enabled, Blender will open a webbrowser',
default=True default=True
) )
@@ -205,9 +284,16 @@ class BlenderCloudPreferences(AddonPreferences):
# Image Share stuff # Image Share stuff
share_box = layout.box() share_box = layout.box()
share_box.label('Image Sharing on Blender Cloud', icon_value=icon('CLOUD')) share_box.label('Image Sharing on Blender Cloud', icon_value=icon('CLOUD'))
texture_box.enabled = msg_icon != 'ERROR'
share_box.prop(self, 'open_browser_after_share') share_box.prop(self, 'open_browser_after_share')
# Attract stuff
attract_box = layout.box()
self.draw_attract_buttons(attract_box, self.attract_project)
# Flamenco stuff
flamenco_box = layout.box()
self.draw_flamenco_buttons(flamenco_box, self.flamenco_manager, context)
def draw_subscribe_button(self, layout): def draw_subscribe_button(self, layout):
layout.operator('pillar.subscribe', icon='WORLD') layout.operator('pillar.subscribe', icon='WORLD')
@@ -242,12 +328,90 @@ class BlenderCloudPreferences(AddonPreferences):
else: else:
row_pull.label('Cloud Sync is running.') row_pull.label('Cloud Sync is running.')
def draw_attract_buttons(self, attract_box, bcp: BlenderCloudProjectGroup):
attract_row = attract_box.row(align=True)
attract_row.label('Attract', icon_value=icon('CLOUD'))
attract_row.enabled = bcp.status in {'NONE', 'IDLE'}
row_buttons = attract_row.row(align=True)
projects = bcp.available_projects
project = bcp.project
if bcp.status in {'NONE', 'IDLE'}:
if not projects or not project:
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')
else:
row_buttons.label('Fetching available projects.')
attract_box.prop(self, 'attract_project_local_path')
def draw_flamenco_buttons(self, flamenco_box, bcp: flamenco.FlamencoManagerGroup, context):
flamenco_row = flamenco_box.row(align=True)
flamenco_row.label('Flamenco', icon_value=icon('CLOUD'))
flamenco_row.enabled = bcp.status in {'NONE', 'IDLE'}
row_buttons = flamenco_row.row(align=True)
if bcp.status in {'NONE', 'IDLE'}:
if not bcp.available_managers or not bcp.manager:
row_buttons.operator('flamenco.managers',
text='Find Flamenco Managers',
icon='FILE_REFRESH')
else:
row_buttons.prop(bcp, 'manager', text='Manager')
row_buttons.operator('flamenco.managers',
text='',
icon='FILE_REFRESH')
else:
row_buttons.label('Fetching available managers.')
path_box = flamenco_box.row(align=True)
path_box.prop(self, 'flamenco_job_file_path')
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_box = job_output_box.row(align=True)
path_box.prop(self, 'flamenco_job_output_path')
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_job_output_strip_components',
text='Strip Components')
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(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('Blend file is not in your project path, '
'unable to give output path example.')
flamenco_box.prop(self, 'flamenco_open_browser_after_submit')
# TODO: make a reusable way to select projects, and use that for Attract and Flamenco.
note_box = flamenco_box.column(align=True)
note_box.label('NOTE: For now, Flamenco uses the same project as Attract.')
note_box.label('This will change in a future version of the add-on.')
class PillarCredentialsUpdate(pillar.PillarOperatorMixin, class PillarCredentialsUpdate(pillar.PillarOperatorMixin,
Operator): Operator):
"""Updates the Pillar URL and tests the new URL.""" """Updates the Pillar URL and tests the new URL."""
bl_idname = 'pillar.credentials_update' bl_idname = 'pillar.credentials_update'
bl_label = 'Update credentials' bl_label = 'Update credentials'
bl_description = 'Resynchronises your Blender ID login with Blender Cloud'
log = logging.getLogger('bpy.ops.%s' % bl_idname) log = logging.getLogger('bpy.ops.%s' % bl_idname)
@@ -294,6 +458,7 @@ class PILLAR_OT_subscribe(Operator):
"""Opens a browser to subscribe the user to the Cloud.""" """Opens a browser to subscribe the user to the Cloud."""
bl_idname = 'pillar.subscribe' bl_idname = 'pillar.subscribe'
bl_label = 'Subscribe to the Cloud' 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): def execute(self, context):
import webbrowser import webbrowser
@@ -304,6 +469,60 @@ class PILLAR_OT_subscribe(Operator):
return {'FINISHED'} 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().attract_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': '-_created',
'projection': {'_id': True,
'name': True},
})
projects_shared = await pillar_call(
pillarsdk.Project.all,
{'where': {'user': {'$ne': self.user_id},
'permissions.groups.group': {'$in': self.db_user.groups}},
'sort': '-_created',
'projection': {'_id': True,
'name': True},
})
# We need to convert to regular dicts before storing in ID properties.
# Also don't store more properties than we need.
projects = [{'_id': p['_id'], 'name': p['name']} for p in projects_user['_items']] + \
[{'_id': p['_id'], 'name': p['name']} for p in projects_shared['_items']]
preferences().attract_project.available_projects = projects
self.quit()
def quit(self):
preferences().attract_project.status = 'IDLE'
super().quit()
class PILLAR_PT_image_custom_properties(rna_prop_ui.PropertyPanel, bpy.types.Panel): class PILLAR_PT_image_custom_properties(rna_prop_ui.PropertyPanel, bpy.types.Panel):
"""Shows custom properties in the image editor.""" """Shows custom properties in the image editor."""
@@ -353,10 +572,12 @@ def icon(icon_name: str) -> int:
def register(): def register():
bpy.utils.register_class(BlenderCloudProjectGroup)
bpy.utils.register_class(BlenderCloudPreferences) bpy.utils.register_class(BlenderCloudPreferences)
bpy.utils.register_class(PillarCredentialsUpdate) bpy.utils.register_class(PillarCredentialsUpdate)
bpy.utils.register_class(SyncStatusProperties) bpy.utils.register_class(SyncStatusProperties)
bpy.utils.register_class(PILLAR_OT_subscribe) bpy.utils.register_class(PILLAR_OT_subscribe)
bpy.utils.register_class(PILLAR_OT_projects)
bpy.utils.register_class(PILLAR_PT_image_custom_properties) bpy.utils.register_class(PILLAR_PT_image_custom_properties)
addon_prefs = preferences() addon_prefs = preferences()
@@ -385,10 +606,12 @@ def register():
def unregister(): def unregister():
unload_custom_icons() unload_custom_icons()
bpy.utils.unregister_class(BlenderCloudProjectGroup)
bpy.utils.unregister_class(PillarCredentialsUpdate) bpy.utils.unregister_class(PillarCredentialsUpdate)
bpy.utils.unregister_class(BlenderCloudPreferences) bpy.utils.unregister_class(BlenderCloudPreferences)
bpy.utils.unregister_class(SyncStatusProperties) bpy.utils.unregister_class(SyncStatusProperties)
bpy.utils.unregister_class(PILLAR_OT_subscribe) bpy.utils.unregister_class(PILLAR_OT_subscribe)
bpy.utils.unregister_class(PILLAR_OT_projects)
bpy.utils.unregister_class(PILLAR_PT_image_custom_properties) bpy.utils.unregister_class(PILLAR_PT_image_custom_properties)
del WindowManager.last_blender_cloud_location del WindowManager.last_blender_cloud_location

View File

@@ -0,0 +1,639 @@
# ##### 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 #####
"""Flamenco interface.
The preferences are managed blender.py, the rest of the Flamenco-specific stuff is here.
"""
import functools
import logging
from pathlib import Path, PurePath
import typing
import bpy
from bpy.types import AddonPreferences, Operator, WindowManager, Scene, PropertyGroup
from bpy.props import StringProperty, EnumProperty, PointerProperty, BoolProperty, IntProperty
from .. import async_loop, pillar
from ..utils import pyside_cache, redraw
log = logging.getLogger(__name__)
@pyside_cache('manager')
def available_managers(self, context):
"""Returns the list of items used by a manager-selector EnumProperty."""
from ..blender import preferences
mngrs = preferences().flamenco_manager.available_managers
if not mngrs:
return [('', 'No managers available in your Blender Cloud', '')]
return [(p['_id'], p['name'], '') for p in mngrs]
class FlamencoManagerGroup(PropertyGroup):
manager = EnumProperty(
items=available_managers,
name='Flamenco Manager',
description='Which Flamenco Manager to use for jobs')
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 Flamenco managers from Blender Cloud'),
],
name='status',
update=redraw)
# List of managers is stored in 'available_managers' ID property,
# because I don't know how to store a variable list of strings in a proper RNA property.
@property
def available_managers(self) -> list:
return self.get('available_managers', [])
@available_managers.setter
def available_managers(self, new_managers):
self['available_managers'] = new_managers
class FLAMENCO_OT_fmanagers(async_loop.AsyncModalOperatorMixin,
pillar.AuthenticatedPillarOperatorMixin,
Operator):
"""Fetches the Flamenco Managers available to the user"""
bl_idname = 'flamenco.managers'
bl_label = 'Fetch available Flamenco Managers'
stop_upon_exception = True
log = logging.getLogger('%s.FLAMENCO_OT_fmanagers' % __name__)
@property
def mypref(self) -> FlamencoManagerGroup:
from ..blender import preferences
return preferences().flamenco_manager
async def async_execute(self, context):
if not await self.authenticate(context):
return
from .sdk import Manager
from ..pillar import pillar_call
self.log.info('Going to fetch managers for user %s', self.user_id)
self.mypref.status = 'FETCHING'
managers = await pillar_call(Manager.all)
# We need to convert to regular dicts before storing in ID properties.
# Also don't store more properties than we need.
as_list = [{'_id': p['_id'], 'name': p['name']} for p in managers['_items']]
self.mypref.available_managers = as_list
self.quit()
def quit(self):
self.mypref.status = 'IDLE'
super().quit()
class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin,
pillar.AuthenticatedPillarOperatorMixin,
Operator):
"""Performs a Blender render on Flamenco."""
bl_idname = 'flamenco.render'
bl_label = 'Render on Flamenco'
bl_description = __doc__.rstrip('.')
stop_upon_exception = True
log = logging.getLogger('%s.FLAMENCO_OT_render' % __name__)
async def async_execute(self, context):
if not await self.authenticate(context):
return
from ..blender import preferences
scene = context.scene
# Save to a different file, specifically for Flamenco.
context.window_manager.flamenco_status = 'PACKING'
filepath = await self._save_blendfile(context)
# Determine where the render output will be stored.
render_output = render_output_path(context, filepath)
if render_output is None:
self.report({'ERROR'}, 'Current file is outside of project path.')
self.quit()
return
self.log.info('Will output render files to %s', render_output)
# BAM-pack the files to the destination directory.
outfile, missing_sources = await self.bam_pack(filepath)
if not outfile:
return
# Create the job at Flamenco Server.
prefs = preferences()
context.window_manager.flamenco_status = 'COMMUNICATING'
settings = {'blender_cmd': '{blender}',
'chunk_size': scene.flamenco_render_fchunk_size,
'filepath': str(outfile),
'frames': scene.flamenco_render_frame_range,
'render_output': str(render_output),
}
# Add extra settings specific to the job type
if scene.flamenco_render_job_type == 'blender-render-progressive':
samples = scene.cycles.samples
if scene.cycles.use_square_samples:
samples **= 2
settings['cycles_num_chunks'] = scene.flamenco_render_schunk_count
settings['cycles_sample_count'] = samples
settings['format'] = 'EXR'
try:
job_info = await create_job(self.user_id,
prefs.attract_project.project,
prefs.flamenco_manager.manager,
scene.flamenco_render_job_type,
settings,
'Render %s' % filepath.name,
priority=scene.flamenco_render_job_priority)
except Exception as ex:
self.report({'ERROR'}, 'Error creating Flamenco job: %s' % ex)
self.quit()
return
# Store the job ID in a file in the output dir.
with open(str(outfile.parent / 'jobinfo.json'), 'w', encoding='utf8') as outfile:
import json
job_info['missing_files'] = [str(mf) for mf in missing_sources]
json.dump(job_info, outfile, sort_keys=True, indent=4)
# We can now remove the local copy we made with bpy.ops.wm.save_as_mainfile().
# Strictly speaking we can already remove it after the BAM-pack, but it may come in
# handy in case of failures.
try:
self.log.info('Removing temporary file %s', filepath)
filepath.unlink()
except Exception as ex:
self.report({'ERROR'}, 'Unable to remove file: %s' % ex)
self.quit()
return
if prefs.flamenco_open_browser_after_submit:
import webbrowser
from urllib.parse import urljoin
from ..blender import PILLAR_WEB_SERVER_URL
url = urljoin(PILLAR_WEB_SERVER_URL, '/flamenco/jobs/%s/redir' % job_info['_id'])
webbrowser.open_new_tab(url)
# Do a final report.
if missing_sources:
names = (ms.name for ms in missing_sources)
self.report({'WARNING'}, 'Flamenco job created with missing files: %s' %
'; '.join(names))
else:
self.report({'INFO'}, 'Flamenco job created.')
self.quit()
async def _save_blendfile(self, context):
"""Save to a different file, specifically for Flamenco.
We shouldn't overwrite the artist's file.
We can compress, since this file won't be managed by SVN and doesn't need diffability.
"""
render = context.scene.render
# Remember settings we need to restore after saving.
old_use_file_extension = render.use_file_extension
old_use_overwrite = render.use_overwrite
old_use_placeholder = render.use_placeholder
try:
# The file extension should be determined by the render settings, not necessarily
# by the setttings in the output panel.
render.use_file_extension = True
# Rescheduling should not overwrite existing frames.
render.use_overwrite = False
render.use_placeholder = False
filepath = Path(context.blend_data.filepath).with_suffix('.flamenco.blend')
self.log.info('Saving copy to temporary file %s', filepath)
bpy.ops.wm.save_as_mainfile(filepath=str(filepath),
compress=True,
copy=True)
finally:
# Restore the settings we changed, even after an exception.
render.use_file_extension = old_use_file_extension
render.use_overwrite = old_use_overwrite
render.use_placeholder = old_use_placeholder
return filepath
def quit(self):
super().quit()
bpy.context.window_manager.flamenco_status = 'IDLE'
async def bam_pack(self, filepath: Path) -> (typing.Optional[Path], typing.List[Path]):
"""BAM-packs the blendfile to the destination directory.
Returns the path of the destination blend file.
:param filepath: the blend file to pack (i.e. the current blend file)
:returns: the destination blend file, or None if there were errors BAM-packing,
and a list of missing paths.
"""
from datetime import datetime
from ..blender import preferences
from . import bam_interface
prefs = preferences()
# Create a unique directory that is still more or less identifyable.
# This should work better than a random ID.
# BAM doesn't like output directories that end in '.blend'.
unique_dir = '%s-%s-%s' % (datetime.now().isoformat('-').replace(':', ''),
self.db_user['username'],
filepath.stem)
outdir = Path(prefs.flamenco_job_file_path) / unique_dir
outfile = outdir / filepath.name
try:
outdir.mkdir(parents=True)
except Exception as ex:
self.log.exception('Unable to create output path %s', outdir)
self.report({'ERROR'}, 'Unable to create output path: %s' % ex)
self.quit()
return None, []
try:
missing_sources = await bam_interface.bam_copy(filepath, outfile)
except bam_interface.CommandExecutionError as ex:
self.log.exception('Unable to execute BAM pack')
self.report({'ERROR'}, 'Unable to execute BAM pack: %s' % ex)
self.quit()
return None, []
return outfile, missing_sources
class FLAMENCO_OT_scene_to_frame_range(Operator):
"""Sets the scene frame range as the Flamenco render frame range."""
bl_idname = 'flamenco.scene_to_frame_range'
bl_label = 'Sets the scene frame range as the Flamenco render frame range'
bl_description = __doc__.rstrip('.')
def execute(self, context):
s = context.scene
s.flamenco_render_frame_range = '%i-%i' % (s.frame_start, s.frame_end)
return {'FINISHED'}
class FLAMENCO_OT_copy_files(Operator,
async_loop.AsyncModalOperatorMixin):
"""Uses BAM to copy the current blendfile + dependencies to the target directory."""
bl_idname = 'flamenco.copy_files'
bl_label = 'Copy files to target'
bl_description = __doc__.rstrip('.')
stop_upon_exception = True
async def async_execute(self, context):
from pathlib import Path
from . import bam_interface
from ..blender import preferences
context.window_manager.flamenco_status = 'PACKING'
missing_sources = await bam_interface.bam_copy(
Path(context.blend_data.filepath),
Path(preferences().flamenco_job_file_path),
)
if missing_sources:
names = (ms.name for ms in missing_sources)
self.report({'ERROR'}, 'Missing source files: %s' % '; '.join(names))
self.quit()
def quit(self):
super().quit()
bpy.context.window_manager.flamenco_status = 'IDLE'
class FLAMENCO_OT_explore_file_path(Operator):
"""Opens the Flamenco job storage path in a file explorer."""
bl_idname = 'flamenco.explore_file_path'
bl_label = 'Open in file explorer'
bl_description = __doc__.rstrip('.')
path = StringProperty(name='Path', description='Path to explore', subtype='DIR_PATH')
def execute(self, context):
import platform
import subprocess
import os
if platform.system() == "Windows":
os.startfile(self.path)
elif platform.system() == "Darwin":
subprocess.Popen(["open", self.path])
else:
subprocess.Popen(["xdg-open", self.path])
return {'FINISHED'}
async def create_job(user_id: str,
project_id: str,
manager_id: str,
job_type: str,
job_settings: dict,
job_name: str = None,
*,
priority: int = 50,
job_description: str = None) -> dict:
"""Creates a render job at Flamenco Server, returning the job object as dictionary."""
import json
from .sdk import Job
from ..pillar import pillar_call
job_attrs = {
'status': 'queued',
'priority': priority,
'name': job_name,
'settings': job_settings,
'job_type': job_type,
'user': user_id,
'manager': manager_id,
'project': project_id,
}
if job_description:
job_attrs['description'] = job_description
log.info('Going to create Flamenco job:\n%s',
json.dumps(job_attrs, indent=4, sort_keys=True))
job = Job(job_attrs)
await pillar_call(job.create)
log.info('Job created succesfully: %s', job._id)
return job.to_dict()
def is_image_type(render_output_type: str) -> bool:
"""Determines whether the render output type is an image (True) or video (False)."""
# This list is taken from rna_scene.c:273, rna_enum_image_type_items.
video_types = {'AVI_JPEG', 'AVI_RAW', 'FRAMESERVER', 'FFMPEG', 'QUICKTIME'}
return render_output_type not in video_types
@functools.lru_cache(1)
def _render_output_path(
local_project_path: str,
blend_filepath: Path,
flamenco_job_output_strip_components: int,
flamenco_job_output_path: str,
render_image_format: str,
flamenco_render_frame_range: str,
) -> typing.Optional[PurePath]:
"""Cached version of render_output_path()
This ensures that redraws of the Flamenco Render and Add-on preferences panels
is fast.
"""
try:
project_path = Path(bpy.path.abspath(local_project_path)).resolve()
except FileNotFoundError:
# Path.resolve() will raise a FileNotFoundError if the project path doesn't exist.
return None
try:
proj_rel = blend_filepath.parent.relative_to(project_path)
except ValueError:
return None
rel_parts = proj_rel.parts[flamenco_job_output_strip_components:]
output_top = Path(flamenco_job_output_path)
# Strip off '.flamenco' too; we use 'xxx.flamenco.blend' as job file, but
# don't want to have all the output paths ending in '.flamenco'.
stem = blend_filepath.stem
if stem.endswith('.flamenco'):
stem = stem[:-9]
dir_components = output_top.joinpath(*rel_parts) / stem
# Blender will have to append the file extensions by itself.
if is_image_type(render_image_format):
return dir_components / '######'
return dir_components / flamenco_render_frame_range
def render_output_path(context, filepath: Path = None) -> typing.Optional[PurePath]:
"""Returns the render output path to be sent to Flamenco.
:param context: the Blender context (used to find Flamenco preferences etc.)
:param filepath: the Path of the blend file to render, or None for the current file.
Returns None when the current blend file is outside the project path.
"""
from ..blender import preferences
scene = context.scene
prefs = preferences()
if filepath is None:
filepath = Path(context.blend_data.filepath)
return _render_output_path(
prefs.attract_project_local_path,
filepath,
prefs.flamenco_job_output_strip_components,
prefs.flamenco_job_output_path,
scene.render.image_settings.file_format,
scene.flamenco_render_frame_range,
)
class FLAMENCO_PT_render(bpy.types.Panel):
bl_label = "Flamenco Render"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = "render"
bl_options = {'DEFAULT_CLOSED'}
def draw(self, context):
layout = self.layout
from ..blender import preferences
prefs = preferences()
labeled_row = layout.split(0.25, align=True)
labeled_row.label('Job Type:')
labeled_row.prop(context.scene, 'flamenco_render_job_type', text='')
labeled_row = layout.split(0.25, align=True)
labeled_row.label('Frame Range:')
prop_btn_row = labeled_row.row(align=True)
prop_btn_row.prop(context.scene, 'flamenco_render_frame_range', text='')
prop_btn_row.operator('flamenco.scene_to_frame_range', text='', icon='ARROW_LEFTRIGHT')
layout.prop(context.scene, 'flamenco_render_job_priority')
layout.prop(context.scene, 'flamenco_render_fchunk_size')
if getattr(context.scene, 'flamenco_render_job_type', None) == 'blender-render-progressive':
layout.prop(context.scene, 'flamenco_render_schunk_count')
readonly_stuff = layout.column(align=True)
labeled_row = readonly_stuff.split(0.25, align=True)
labeled_row.label('Storage:')
prop_btn_row = labeled_row.row(align=True)
prop_btn_row.label(prefs.flamenco_job_file_path)
props = prop_btn_row.operator(FLAMENCO_OT_explore_file_path.bl_idname,
text='', icon='DISK_DRIVE')
props.path = prefs.flamenco_job_file_path
labeled_row = readonly_stuff.split(0.25, align=True)
labeled_row.label('Output:')
prop_btn_row = labeled_row.row(align=True)
render_output = render_output_path(context)
if render_output is None:
prop_btn_row.label('Unable to render with Flamenco, outside of project directory.')
else:
prop_btn_row.label(str(render_output))
props = prop_btn_row.operator(FLAMENCO_OT_explore_file_path.bl_idname,
text='', icon='DISK_DRIVE')
props.path = str(render_output.parent)
flamenco_status = context.window_manager.flamenco_status
if flamenco_status == 'IDLE':
layout.operator(FLAMENCO_OT_render.bl_idname,
text='Render on Flamenco',
icon='RENDER_ANIMATION')
elif flamenco_status == 'PACKING':
layout.label('Flamenco is packing your file + dependencies')
elif flamenco_status == 'COMMUNICATING':
layout.label('Communicating with Flamenco Server')
else:
layout.label('Unknown Flamenco status %s' % flamenco_status)
def register():
from ..utils import redraw
bpy.utils.register_class(FlamencoManagerGroup)
bpy.utils.register_class(FLAMENCO_OT_fmanagers)
bpy.utils.register_class(FLAMENCO_OT_render)
bpy.utils.register_class(FLAMENCO_OT_scene_to_frame_range)
bpy.utils.register_class(FLAMENCO_OT_copy_files)
bpy.utils.register_class(FLAMENCO_OT_explore_file_path)
bpy.utils.register_class(FLAMENCO_PT_render)
scene = bpy.types.Scene
scene.flamenco_render_fchunk_size = IntProperty(
name='Frame Chunk Size',
description='Maximum number of frames to render per task',
min=1,
default=1,
)
scene.flamenco_render_schunk_count = IntProperty(
name='Number of Sample Chunks',
description='Number of Cycles samples chunks to use per frame',
min=2,
default=3,
soft_max=10,
)
scene.flamenco_render_frame_range = StringProperty(
name='Frame Range',
description='Frames to render, in "printer range" notation'
)
scene.flamenco_render_job_type = EnumProperty(
name='Job Type',
items=[
('blender-render', 'Simple Render', 'Simple frame-by-frame render'),
('blender-render-progressive', 'Progressive Render',
'Each frame is rendered multiple times with different Cycles sample chunks, then combined'),
]
)
scene.flamenco_render_job_priority = IntProperty(
name='Job Priority',
min=0,
default=50,
max=100,
description='Higher numbers mean higher priority'
)
bpy.types.WindowManager.flamenco_status = EnumProperty(
items=[
('IDLE', 'IDLE', 'Not doing anything.'),
('PACKING', 'PACKING', 'BAM-packing all dependencies.'),
('COMMUNICATING', 'COMMUNICATING', 'Communicating with Flamenco Server.'),
],
name='flamenco_status',
default='IDLE',
description='Current status of the Flamenco add-on',
update=redraw)
def unregister():
bpy.utils.unregister_module(__name__)
try:
del bpy.types.Scene.flamenco_render_fchunk_size
except AttributeError:
pass
try:
del bpy.types.Scene.flamenco_render_schunk_count
except AttributeError:
pass
try:
del bpy.types.Scene.flamenco_render_frame_range
except AttributeError:
pass
try:
del bpy.types.Scene.flamenco_render_job_type
except AttributeError:
pass
try:
del bpy.types.Scene.flamenco_render_job_priority
except AttributeError:
pass
try:
del bpy.types.WindowManager.flamenco_status
except AttributeError:
pass

View File

@@ -0,0 +1,144 @@
"""BAM packing interface for Flamenco."""
import logging
from pathlib import Path
import typing
# Timeout of the BAM subprocess, in seconds.
SUBPROC_READLINE_TIMEOUT = 600
log = logging.getLogger(__name__)
class CommandExecutionError(Exception):
"""Raised when there was an error executing a BAM command."""
pass
async def bam_copy(base_blendfile: Path, target_blendfile: Path) -> typing.List[Path]:
"""Uses BAM to copy the given file and dependencies to the target blendfile.
Due to the way blendfile_pack.py is programmed/structured, we cannot import it
and call a function; it has to be run in a subprocess.
:raises: asyncio.CanceledError if the task was cancelled.
:raises: asyncio.TimeoutError if reading a line from the BAM process timed out.
:raises: CommandExecutionError if the subprocess failed or output invalid UTF-8.
:returns: a list of missing sources; hopefully empty.
"""
import asyncio
import shlex
import subprocess
import bpy
import io_blend_utils
args = [
bpy.app.binary_path_python,
'-m', 'bam.pack',
'--input', str(base_blendfile),
'--output', str(target_blendfile),
'--mode', 'FILE',
]
cmd_to_log = ' '.join(shlex.quote(s) for s in args)
log.info('Executing %s', cmd_to_log)
proc = await asyncio.create_subprocess_exec(
*args,
env={'PYTHONPATH': io_blend_utils.pythonpath()},
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
missing_sources = []
try:
while not proc.stdout.at_eof():
line = await asyncio.wait_for(proc.stdout.readline(),
SUBPROC_READLINE_TIMEOUT)
if not line:
# EOF received, so let's bail.
break
try:
line = line.decode('utf8')
except UnicodeDecodeError as ex:
raise CommandExecutionError('Command produced non-UTF8 output, '
'aborting: %s' % ex)
line = line.rstrip()
if 'source missing:' in line:
path = parse_missing_source(line)
missing_sources.append(path)
log.warning('Source is missing: %s', path)
log.info(' %s', line)
finally:
if proc.returncode is None:
# Always wait for the process, to avoid zombies.
try:
proc.kill()
except ProcessLookupError:
# The process is already stopped, so killing is impossible. That's ok.
log.debug("The process was already stopped, aborting is impossible. That's ok.")
await proc.wait()
log.info('The process stopped with status code %i', proc.returncode)
if proc.returncode:
raise CommandExecutionError('Process stopped with status %i' % proc.returncode)
return missing_sources
def parse_missing_source(line: str) -> Path:
r"""Parses a "missing source" line into a pathlib.Path.
>>> parse_missing_source(r" source missing: b'D\xc3\xaffficult \xc3\x9cTF-8 filename'")
PosixPath('Dïfficult ÜTF-8 filename')
>>> parse_missing_source(r" source missing: b'D\xfffficult Win1252 f\xeflen\xe6me'")
PosixPath('D<EFBFBD>fficult Win1252 f<>len<65>me')
"""
_, missing_source = line.split(': ', 1)
missing_source_as_bytes = parse_byte_literal(missing_source.strip())
# The file could originate from any platform, so UTF-8 and the current platform's
# filesystem encodings are just guesses.
try:
missing_source = missing_source_as_bytes.decode('utf8')
except UnicodeDecodeError:
import sys
try:
missing_source = missing_source_as_bytes.decode(sys.getfilesystemencoding())
except UnicodeDecodeError:
missing_source = missing_source_as_bytes.decode('ascii', errors='replace')
path = Path(missing_source)
return path
def parse_byte_literal(bytes_literal: str) -> bytes:
r"""Parses a repr(bytes) output into a bytes object.
>>> parse_byte_literal(r"b'D\xc3\xaffficult \xc3\x9cTF-8 filename'")
b'D\xc3\xaffficult \xc3\x9cTF-8 filename'
>>> parse_byte_literal(r"b'D\xeffficult Win1252 f\xeflen\xe6me'")
b'D\xeffficult Win1252 f\xeflen\xe6me'
"""
# Some very basic assertions to make sure we have a proper bytes literal.
assert bytes_literal[0] == "b"
assert bytes_literal[1] in {'"', "'"}
assert bytes_literal[-1] == bytes_literal[1]
import ast
return ast.literal_eval(bytes_literal)
if __name__ == '__main__':
import doctest
doctest.testmod()

View File

@@ -0,0 +1,13 @@
from pillarsdk.resource import List, Find, Create
class Manager(List, Find):
"""Manager class wrapping the REST nodes endpoint"""
path = 'flamenco/managers'
class Job(List, Find, Create):
"""Job class wrapping the REST nodes endpoint
"""
path = 'flamenco/jobs'
ensure_query_projections = {'project': 1}

View File

@@ -107,11 +107,7 @@ class PILLAR_OT_image_share(pillar.PillarOperatorMixin,
self.report({'ERROR'}, 'Datablock is dirty, save it first.') self.report({'ERROR'}, 'Datablock is dirty, save it first.')
return {'CANCELLED'} return {'CANCELLED'}
async_loop.AsyncModalOperatorMixin.invoke(self, context, event) return async_loop.AsyncModalOperatorMixin.invoke(self, context, event)
self.log.info('Starting sharing')
self._new_async_task(self.async_execute(context))
return {'RUNNING_MODAL'}
async def async_execute(self, context): async def async_execute(self, context):
"""Entry point of the asynchronous operator.""" """Entry point of the asynchronous operator."""
@@ -121,15 +117,15 @@ class PILLAR_OT_image_share(pillar.PillarOperatorMixin,
try: try:
# Refresh credentials # Refresh credentials
try: try:
self.user_id = await self.check_credentials(context, db_user = await self.check_credentials(context, REQUIRES_ROLES_FOR_IMAGE_SHARING)
REQUIRES_ROLES_FOR_IMAGE_SHARING) self.user_id = db_user['_id']
self.log.debug('Found user ID: %s', self.user_id) self.log.debug('Found user ID: %s', self.user_id)
except pillar.NotSubscribedToCloudError: except pillar.NotSubscribedToCloudError:
self.log.exception('User not subscribed to cloud.') self.log.exception('User not subscribed to cloud.')
self.report({'ERROR'}, 'Please subscribe to the Blender Cloud.') self.report({'ERROR'}, 'Please subscribe to the Blender Cloud.')
self._state = 'QUIT' self._state = 'QUIT'
return return
except pillar.CredentialsNotSyncedError: except pillar.UserNotLoggedInError:
self.log.exception('Error checking/refreshing credentials.') self.log.exception('Error checking/refreshing credentials.')
self.report({'ERROR'}, 'Please log in on Blender ID first.') self.report({'ERROR'}, 'Please log in on Blender ID first.')
self._state = 'QUIT' self._state = 'QUIT'
@@ -287,7 +283,7 @@ class PILLAR_OT_image_share(pillar.PillarOperatorMixin,
async def upload_screenshot(self, context) -> pillarsdk.Node: async def upload_screenshot(self, context) -> pillarsdk.Node:
"""Takes a screenshot, saves it to a temp file, and uploads it.""" """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.name = datetime.datetime.now().strftime('Screenshot-%Y-%m-%d-%H%M%S.png')
self.report({'INFO'}, "Uploading %s '%s'" % (self.target.lower(), self.name)) self.report({'INFO'}, "Uploading %s '%s'" % (self.target.lower(), self.name))
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:

View File

@@ -146,6 +146,13 @@ def blender_id_subclient() -> dict:
return subclient return subclient
def pillar_user_uuid() -> str:
"""Returns the UUID of the Pillar user."""
import blender_id
return blender_id.get_subclient_user_id(SUBCLIENT_ID)
def pillar_api(pillar_endpoint: str = None, caching=True) -> pillarsdk.Api: def pillar_api(pillar_endpoint: str = None, caching=True) -> pillarsdk.Api:
"""Returns the Pillar SDK API object for the current user. """Returns the Pillar SDK API object for the current user.
@@ -198,6 +205,12 @@ pillar_semaphore = asyncio.Semaphore(3)
async def pillar_call(pillar_func, *args, caching=True, **kwargs): async def pillar_call(pillar_func, *args, caching=True, **kwargs):
"""Calls a Pillar function.
A semaphore is used to ensure that there won't be too many
calls to Pillar simultaneously.
"""
partial = functools.partial(pillar_func, *args, api=pillar_api(caching=caching), **kwargs) partial = functools.partial(pillar_func, *args, api=pillar_api(caching=caching), **kwargs)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
@@ -205,6 +218,12 @@ async def pillar_call(pillar_func, *args, caching=True, **kwargs):
return await loop.run_in_executor(None, partial) return await loop.run_in_executor(None, partial)
def sync_call(pillar_func, *args, caching=True, **kwargs):
"""Synchronous call to Pillar, ensures the correct Api object is used."""
return pillar_func(*args, api=pillar_api(caching=caching), **kwargs)
async def check_pillar_credentials(required_roles: set): async def check_pillar_credentials(required_roles: set):
"""Tries to obtain the user at Pillar using the user's credentials. """Tries to obtain the user at Pillar using the user's credentials.
@@ -241,7 +260,7 @@ async def check_pillar_credentials(required_roles: set):
profile.save_json() profile.save_json()
raise NotSubscribedToCloudError() raise NotSubscribedToCloudError()
return pillar_user_id return db_user
async def refresh_pillar_credentials(required_roles: set): async def refresh_pillar_credentials(required_roles: set):
@@ -256,7 +275,7 @@ async def refresh_pillar_credentials(required_roles: set):
import blender_id import blender_id
from . import blender from . import blender
pillar_endpoint = blender.preferences().pillar_server.rstrip('/') pillar_endpoint = blender.preferences().pillar_server
# Create a subclient token and send it to Pillar. # Create a subclient token and send it to Pillar.
# May raise a blender_id.BlenderIdCommError # May raise a blender_id.BlenderIdCommError
@@ -780,15 +799,16 @@ def is_cancelled(future: asyncio.Future) -> bool:
class PillarOperatorMixin: class PillarOperatorMixin:
async def check_credentials(self, context, required_roles) -> bool: async def check_credentials(self, context, required_roles) -> bool:
"""Checks credentials with Pillar, and if ok returns the user ID. """Checks credentials with Pillar, and if ok returns the user document from Pillar/MongoDB.
Returns None if the user cannot be found, or if the user is not a Cloud subscriber. :raises UserNotLoggedInError: if the user is not logged in
:raises NotSubscribedToCloudError: if the user does not have any of the required roles
""" """
# self.report({'INFO'}, 'Checking Blender Cloud credentials') # self.report({'INFO'}, 'Checking Blender Cloud credentials')
try: try:
user_id = await check_pillar_credentials(required_roles) db_user = await check_pillar_credentials(required_roles)
except NotSubscribedToCloudError: except NotSubscribedToCloudError:
self._log_subscription_needed() self._log_subscription_needed()
raise raise
@@ -796,20 +816,22 @@ class PillarOperatorMixin:
self.log.info('Credentials not synced, re-syncing automatically.') self.log.info('Credentials not synced, re-syncing automatically.')
else: else:
self.log.info('Credentials okay.') self.log.info('Credentials okay.')
return user_id return db_user
try: try:
user_id = await refresh_pillar_credentials(required_roles) db_user = await refresh_pillar_credentials(required_roles)
except NotSubscribedToCloudError: except NotSubscribedToCloudError:
self._log_subscription_needed() self._log_subscription_needed()
raise raise
except CredentialsNotSyncedError:
self.log.info('Credentials not synced after refreshing, handling as not logged in.')
raise UserNotLoggedInError('Not logged in.')
except UserNotLoggedInError: except UserNotLoggedInError:
self.log.error('User not logged in on Blender ID.') self.log.error('User not logged in on Blender ID.')
raise
else: else:
self.log.info('Credentials refreshed and ok.') self.log.info('Credentials refreshed and ok.')
return user_id return db_user
return None
def _log_subscription_needed(self): def _log_subscription_needed(self):
self.log.warning( self.log.warning(
@@ -818,6 +840,32 @@ class PillarOperatorMixin:
'Please subscribe to the blender cloud at https://cloud.blender.org/join') 'Please subscribe to the blender cloud at https://cloud.blender.org/join')
class AuthenticatedPillarOperatorMixin(PillarOperatorMixin):
"""Checks credentials, to be used at the start of async_execute().
Sets self.user_id to the current user's ID, and self.db_user to the user info dict,
if authentication was succesful; sets both to None if not.
"""
async def authenticate(self, context) -> bool:
from . import pillar
self.log.info('Checking credentials')
self.user_id = None
self.db_user = None
try:
self.db_user = await self.check_credentials(context, ())
except pillar.UserNotLoggedInError as ex:
self.log.info('Not logged in error raised: %s', ex)
self.report({'ERROR'}, 'Please log in on Blender ID first.')
self.quit()
return False
self.user_id = self.db_user['_id']
return True
async def find_or_create_node(where: dict, async def find_or_create_node(where: dict,
additional_create_props: dict = None, additional_create_props: dict = None,
projection: dict = None, projection: dict = None,

View File

@@ -234,11 +234,7 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
self.bss_report({'ERROR'}, 'No Blender version to sync for was given.') self.bss_report({'ERROR'}, 'No Blender version to sync for was given.')
return {'CANCELLED'} return {'CANCELLED'}
async_loop.AsyncModalOperatorMixin.invoke(self, context, event) return async_loop.AsyncModalOperatorMixin.invoke(self, context, event)
self.log.info('Starting synchronisation')
self._new_async_task(self.async_execute(context))
return {'RUNNING_MODAL'}
def action_select(self, context): def action_select(self, context):
"""Allows selection of the Blender version to use. """Allows selection of the Blender version to use.
@@ -285,14 +281,15 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
try: try:
# Refresh credentials # Refresh credentials
try: try:
self.user_id = await self.check_credentials(context, REQUIRES_ROLES_FOR_SYNC) 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) log.debug('Found user ID: %s', self.user_id)
except pillar.NotSubscribedToCloudError: except pillar.NotSubscribedToCloudError:
self.log.exception('User not subscribed to cloud.') self.log.exception('User not subscribed to cloud.')
self.bss_report({'SUBSCRIBE'}, 'Please subscribe to the Blender Cloud.') self.bss_report({'SUBSCRIBE'}, 'Please subscribe to the Blender Cloud.')
self._state = 'QUIT' self._state = 'QUIT'
return return
except pillar.CredentialsNotSyncedError: except pillar.UserNotLoggedInError:
self.log.exception('Error checking/refreshing credentials.') self.log.exception('Error checking/refreshing credentials.')
self.bss_report({'ERROR'}, 'Please log in on Blender ID first.') self.bss_report({'ERROR'}, 'Please log in on Blender ID first.')
self._state = 'QUIT' self._state = 'QUIT'

View File

@@ -258,8 +258,9 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
scroll_offset_space_left = 0 scroll_offset_space_left = 0
def invoke(self, context, event): def invoke(self, context, event):
# Refuse to start if the file hasn't been saved. # Refuse to start if the file hasn't been saved. It's okay if
if context.blend_data.is_dirty: # 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 ' self.report({'ERROR'}, 'Please save your Blend file before using '
'the Blender Cloud addon.') 'the Blender Cloud addon.')
return {'CANCELLED'} return {'CANCELLED'}
@@ -288,10 +289,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self._scroll_reset() self._scroll_reset()
context.window.cursor_modal_set('DEFAULT') context.window.cursor_modal_set('DEFAULT')
async_loop.AsyncModalOperatorMixin.invoke(self, context, event) return async_loop.AsyncModalOperatorMixin.invoke(self, context, event)
self._new_async_task(self.async_execute(context))
return {'RUNNING_MODAL'}
def modal(self, context, event): def modal(self, context, event):
result = async_loop.AsyncModalOperatorMixin.modal(self, context, event) result = async_loop.AsyncModalOperatorMixin.modal(self, context, event)
@@ -366,13 +364,13 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self.log.debug('Checking credentials') self.log.debug('Checking credentials')
try: try:
user_id = await self.check_credentials(context, REQUIRED_ROLES_FOR_TEXTURE_BROWSER) db_user = await self.check_credentials(context, REQUIRED_ROLES_FOR_TEXTURE_BROWSER)
except pillar.NotSubscribedToCloudError: except pillar.NotSubscribedToCloudError:
self.log.info('User not subscribed to Blender Cloud.') self.log.info('User not subscribed to Blender Cloud.')
self._show_subscribe_screen() self._show_subscribe_screen()
return None return None
if user_id is None: if db_user is None:
raise pillar.UserNotLoggedInError() raise pillar.UserNotLoggedInError()
await self.async_download_previews() await self.async_download_previews()
@@ -555,6 +553,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
"""Draws the GUI with OpenGL.""" """Draws the GUI with OpenGL."""
drawers = { drawers = {
'INITIALIZING': self._draw_initializing,
'CHECKING_CREDENTIALS': self._draw_checking_credentials, 'CHECKING_CREDENTIALS': self._draw_checking_credentials,
'BROWSING': self._draw_browser, 'BROWSING': self._draw_browser,
'DOWNLOADING_TEXTURE': self._draw_downloading, 'DOWNLOADING_TEXTURE': self._draw_downloading,
@@ -655,6 +654,13 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
'Checking login credentials', 'Checking login credentials',
(0.0, 0.0, 0.2, 0.6)) (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, bgcolour): def _draw_text_on_colour(self, context, text, bgcolour):
content_height, content_width = self._window_size(context) content_height, content_width = self._window_size(context)
bgl.glEnable(bgl.GL_BLEND) bgl.glEnable(bgl.GL_BLEND)
@@ -850,13 +856,6 @@ class PILLAR_OT_switch_hdri(pillar.PillarOperatorMixin,
file_uuid = bpy.props.StringProperty(name='file_uuid', file_uuid = bpy.props.StringProperty(name='file_uuid',
description='File ID to download') description='File ID to download')
def invoke(self, context, event):
async_loop.AsyncModalOperatorMixin.invoke(self, context, event)
self.log.info('Starting')
self._new_async_task(self.async_execute(context))
return {'RUNNING_MODAL'}
async def async_execute(self, context): async def async_execute(self, context):
"""Entry point of the asynchronous operator.""" """Entry point of the asynchronous operator."""
@@ -864,19 +863,20 @@ class PILLAR_OT_switch_hdri(pillar.PillarOperatorMixin,
try: try:
try: try:
user_id = await self.check_credentials(context, REQUIRED_ROLES_FOR_TEXTURE_BROWSER) db_user = await self.check_credentials(context, REQUIRED_ROLES_FOR_TEXTURE_BROWSER)
user_id = db_user['_id']
except pillar.NotSubscribedToCloudError: except pillar.NotSubscribedToCloudError:
self.log.exception('User not subscribed to cloud.') self.log.exception('User not subscribed to cloud.')
self.report({'ERROR'}, 'Please subscribe to the Blender Cloud.') self.report({'ERROR'}, 'Please subscribe to the Blender Cloud.')
self._state = 'QUIT' self._state = 'QUIT'
return return
except pillar.CredentialsNotSyncedError: except pillar.UserNotLoggedInError:
self.log.exception('Error checking/refreshing credentials.') self.log.exception('Error checking/refreshing credentials.')
self.report({'ERROR'}, 'Please log in on Blender ID first.') self.report({'ERROR'}, 'Please log in on Blender ID first.')
self._state = 'QUIT' self._state = 'QUIT'
return return
if user_id is None: if not user_id:
raise pillar.UserNotLoggedInError() raise pillar.UserNotLoggedInError()
await self.download_and_replace(context) await self.download_and_replace(context)
@@ -977,6 +977,11 @@ def _hdri_download_panel(self, current_image):
props.file_uuid = current_image.hdri_variation 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): def hdri_variation_choices(self, context):
if context.area.type == 'IMAGE_EDITOR': if context.area.type == 'IMAGE_EDITOR':
image = context.edit_image image = context.edit_image
@@ -988,8 +993,11 @@ def hdri_variation_choices(self, context):
if 'bcloud_node' not in image: if 'bcloud_node' not in image:
return [] return []
choices = [(file_doc['file'], file_doc['resolution'], '') choices = []
for file_doc in image['bcloud_node']['properties']['files']] 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 return choices

View File

@@ -16,6 +16,8 @@
# #
# ##### END GPL LICENSE BLOCK ##### # ##### END GPL LICENSE BLOCK #####
import pathlib
def sizeof_fmt(num: int, suffix='B') -> str: def sizeof_fmt(num: int, suffix='B') -> str:
"""Returns a human-readable size. """Returns a human-readable size.
@@ -29,3 +31,72 @@ def sizeof_fmt(num: int, suffix='B') -> str:
num /= 1024 num /= 1024
return '%.1f Yi%s' % (num, suffix) return '%.1f Yi%s' % (num, suffix)
def find_in_path(path: pathlib.Path, filename: str) -> 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
def pyside_cache(propname):
"""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
"""
if callable(propname):
raise TypeError('Usage: pyside_cache("property_name")')
def decorator(wrapped):
"""Stores the result of the 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 wrapper(self, context):
result = None
try:
result = wrapped(self, context)
return result
finally:
rna_type, rna_info = getattr(self.bl_rna, propname)
rna_info['_cached_result'] = result
return wrapper
return decorator
def redraw(self, context):
context.area.tag_redraw()

8
requirements-dev.txt Normal file
View File

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

View File

@@ -1,7 +1,7 @@
# Primary requirements: # Primary requirements:
-e git+https://github.com/sybrenstuvel/cachecontrol.git@sybren-filecache-delete-crash-fix#egg=CacheControl -e git+https://github.com/sybrenstuvel/cachecontrol.git@sybren-filecache-delete-crash-fix#egg=CacheControl
lockfile==0.12.2 lockfile==0.12.2
pillarsdk==1.5.0 pillarsdk==1.6.1
wheel==0.29.0 wheel==0.29.0
# Secondary requirements: # Secondary requirements:

View File

@@ -18,11 +18,13 @@
# ##### END GPL LICENSE BLOCK ##### # ##### END GPL LICENSE BLOCK #####
import glob import glob
import os
import sys import sys
import shutil import shutil
import subprocess import subprocess
import re import re
import pathlib import pathlib
import zipfile
from distutils import log from distutils import log
from distutils.core import Command from distutils.core import Command
@@ -168,6 +170,34 @@ class BlenderAddonBdist(bdist):
super().run() 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 # noinspection PyAttributeOutsideInit
class BlenderAddonInstall(install): class BlenderAddonInstall(install):
"""Ensures the module is placed at the root of the zip file.""" """Ensures the module is placed at the root of the zip file."""
@@ -191,16 +221,17 @@ class AvoidEggInfo(install_egg_info):
setup( setup(
cmdclass={'bdist': BlenderAddonBdist, cmdclass={'bdist': BlenderAddonBdist,
'fdist': BlenderAddonFdist,
'install': BlenderAddonInstall, 'install': BlenderAddonInstall,
'install_egg_info': AvoidEggInfo, 'install_egg_info': AvoidEggInfo,
'wheels': BuildWheels}, 'wheels': BuildWheels},
name='blender_cloud', name='blender_cloud',
description='The Blender Cloud addon allows browsing the Blender Cloud from Blender.', description='The Blender Cloud addon allows browsing the Blender Cloud from Blender.',
version='1.4.1', version='1.6.0',
author='Sybren A. Stüvel', author='Sybren A. Stüvel',
author_email='sybren@stuvel.eu', author_email='sybren@stuvel.eu',
packages=find_packages('.'), packages=find_packages('.'),
data_files=[('blender_cloud', ['README.md']), data_files=[('blender_cloud', ['README.md', 'README-flamenco.md']),
('blender_cloud/icons', glob.glob('blender_cloud/icons/*'))], ('blender_cloud/icons', glob.glob('blender_cloud/icons/*'))],
scripts=[], scripts=[],
url='https://developer.blender.org/diffusion/BCA/', url='https://developer.blender.org/diffusion/BCA/',

25
tests/test_utils.py Normal file
View File

@@ -0,0 +1,25 @@
"""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)

15
update_version.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
if [ -z "$1" ]; then
echo "Usage: $0 new-version" >&2
exit 1
fi
BL_INFO_VER=$(echo "$1" | sed 's/\./, /g')
sed "s/version='[^']*'/version='$1'/" -i setup.py
sed "s/'version': ([^)]*)/'version': ($BL_INFO_VER)/" -i blender_cloud/__init__.py
git diff
echo
echo "Don't forget to commit!"