Compare commits

..

193 Commits

Author SHA1 Message Date
bc864737ae Bumped version to 1.11.1 2019-01-04 13:42:12 +01:00
f454a99c4b Bundled missing Texture Browser icons in setup.py 2019-01-04 13:42:04 +01:00
40732e0487 Updated changelog 2019-01-04 11:13:32 +01:00
b86bffbdbb Bumped version to 1.11.0 2019-01-04 11:12:36 +01:00
67f9d40fd3 Blender Sync: fixed missing icon in Blender 2.80
I like the 'DOTSDOWN' icon better, so I keep using it in Blender ≤ 2.79.
2019-01-04 11:09:20 +01:00
c4de4e9990 Fixed some MyPy warnings
This includes using `''` instead of `None` in some cases where an empty
string conveys 'nothing' equally well as `None`; in such cases keeping the
type the same rather than switching to another type is preferred.
2019-01-03 12:07:05 +01:00
6d2e6efa13 Update users of the material after replacing a HDRi
This causes a refresh and immediately shows the new texture in the viewport.
2019-01-03 11:33:19 +01:00
ff9ae0117d Fixed race condition referring to self when operator may have stopped running
The `file_loading` function is called deferred by asyncio, and can thus
be called when the operator has already stopped loading. This is fixed by
not referring to `self` in that function, and taking the logger from the
outer scope.
2019-01-03 11:32:40 +01:00
974d33e3a3 Texture Browser updated for Blender 2.8 drawing
The drawing code has been abstracted into a `draw.py` for Blender 2.8
and `draw_27.py` for earlier versions.
2019-01-03 10:41:42 +01:00
8de3a0bba2 Moved texture browser to its own module
This places it in the same kind of structure as Attract and Flamenco.
2019-01-02 16:47:33 +01:00
6f705b917f Removed local import 2019-01-02 16:47:11 +01:00
02b694f5d4 Bumped version to 1.10.0 and marked as released today 2019-01-02 16:19:23 +01:00
663ebae572 Bumped Blender-Asset-Tracer version to 0.8
This version has lots of Windows-specific fixes.
2019-01-02 16:19:05 +01:00
cb5a116dff Compatibility fix for Blender 2.8
bpy.context.user_preferences was renamed to bpy.context.preferences.
2018-12-28 12:31:33 +01:00
5821611d89 Compatibility fix with Blender 2.79 (Python 3.5) 2018-12-28 12:29:25 +01:00
8bd1faa575 Overwrite when deploying 2018-12-07 14:34:02 +01:00
8899bff5e4 Fixed Flamenco exclusion filter bug
There was a mistake in an older version of the property tooltip, showing
semicolon-separated instead of space-separated. We now just handle both.
2018-12-07 12:25:48 +01:00
4fd4ad7448 Added 'blender-video-chunks' job type
Requires that the file is configured for rendering to Matroska video
files.

Audio is only extracted when there is an audio codec configured. This is
a bit arbitrary, but it's at least a way to tell whether the artist is
considering that there is audio of any relevance in the current blend
file.
2018-12-07 11:28:09 +01:00
4f32b49ad3 Flamenco: Allow BAT-packing of only relative-path assets 2018-12-06 15:46:54 +01:00
1f13b4d249 Updated changelog 2018-12-05 13:01:03 +01:00
ef57dba5d3 Flamenco: Write more extensive information to jobinfo.json
This introduces version 2 of that file.

Version 1:
    - Only the job doc was saved, with 'missing_files' added inside it.

Version 2:
  - '_meta' key was added to indicate version.
  - 'job' is saved in a 'job' key, 'misssing_files' still top-level key.
  - 'exclusion_filter', 'project_settings', and
    'flamenco_manager_settings' keys were added.
2018-12-05 12:57:39 +01:00
419249ee19 Flamenco: Compress all blend files
All blend files in the BAT pack are now compressed, and not just the one
we save from Blender. Requires BAT 0.5 or newer.
2018-11-27 16:40:05 +01:00
113eb8f7ab Flamenco: add fps, output_file_extension, and images_or_video job settings
These are all needed to use FFmpeg on the worker to render a video from
rendered image sequences.

- fps: float, the scene FPS
- images_or_video: either 'images' or 'video', depending on what's being
  output by Blender. We don't support using FFmpeg to join chunked videos
  yet.
- output_file_extension: string like '.png' or '.exr', only set when
  outputting images (since doing this for video requires a lookup table and
  isn't even being used at the moment).
2018-11-21 14:24:32 +01:00
85f911cb59 Generalised saving/loading of project+manager-specific settings + added one
Added the `flamenco_exclude_filter` setting to the set, and also made it
easier to add new settings too.
2018-11-16 17:12:30 +01:00
564c2589b1 Added little script to automate deployment in Blender Animation Studio 2018-11-16 16:54:36 +01:00
80155ed4f4 Fixed storing & loading project+manager-specific settings
The problem was that there was too much storing done in an on-change
handler, causing things to be overwritten. By splitting up some functionality
and properly marking the "we're now loading" bits of code, its' solved.
2018-11-16 16:52:07 +01:00
d8c5c4eecd Cross-platformified my setup.py 'local' hack 2018-11-16 12:20:09 +01:00
3972ce4543 Write wheel files to correct dir in the bdist archive
They were ending up in a `local` directory next to the `blender_cloud`
directory. Probably something to do with newer setuptools? Had the same
issue in the Blender ID add-on.
2018-11-15 17:47:33 +01:00
d75a055149 Updated CHANGELOG 2018-11-12 15:07:03 +01:00
649542daad Prevent crashing Blender when running in the background 2018-11-12 15:02:51 +01:00
1d99751d20 Bumped version to 1.9.4 2018-11-01 18:39:24 +01:00
69028e0cfd Fixed Python 3.6 / 2.79b incompatibilities introduced in 1.9.3 2018-11-01 18:39:06 +01:00
dc7ad296bf Added little reminder for myself 2018-11-01 18:30:18 +01:00
3f2479067c Fixed incompatibility with Python 3.6 (used in Blender 2.79b) 2018-11-01 18:30:10 +01:00
6fefe4ffd8 Bumped version to 1.9.3 2018-10-30 14:17:02 +01:00
62c1c966f6 Attract: draw using the GPU module
The drawing is rather primitive, but it works.
2018-10-30 14:14:33 +01:00
57aadc1817 Attract: added 'open project in browser' button
The button was added to the video sequence editor panel.
2018-10-30 14:14:33 +01:00
7204d4a24c Added bl_category for Attract panel 2018-10-30 14:14:33 +01:00
641b51496a Some drawing code simplification 2018-10-30 14:14:33 +01:00
0562d57513 Attract: fixed class naming and registration 2018-10-30 14:14:33 +01:00
ac19e48895 Changelog update 2018-10-30 10:56:09 +01:00
73d96e5c89 Bumped version to 1.9.2 2018-09-17 18:58:05 +02:00
4bfdac223a Include Python 3.7-compatible pillarsdk 2018-09-17 18:57:57 +02:00
5d6777c74b Bumped version to 1.9.1 2018-09-17 18:47:52 +02:00
f4322f1d1f Updated changelog 2018-09-17 18:47:16 +02:00
13a8595cc0 Don't set prefs.flamenco_manager.manager to a not-in-the-enum value 2018-09-17 18:23:41 +02:00
af413059b0 Bumped version to 1.9.0 2018-09-05 13:35:27 +02:00
4d26ad248e Bumped version to 1.9 for last 2.79-compatible release
The next release will be 2.0 and target Blender 2.80.
2018-09-05 13:31:09 +02:00
d019fd0cf0 Some debug logging code 2018-09-05 13:28:56 +02:00
fb9ffbbc23 Store available managers per project, and store chosen manager too 2018-09-04 17:38:21 +02:00
f6d797512a Moved use of global variable to a context manager 2018-09-04 17:37:07 +02:00
8367abeeb9 Fixed bad registration 2018-09-04 15:36:38 +02:00
2f5f82b1a8 Blender 2.80-compatible unregistration 2018-09-04 14:56:10 +02:00
a04137ec6a Bumped version to 1.9.999 2018-09-04 14:43:57 +02:00
87c90a7f72 Some more Blender 2.80 compatibility 2018-09-04 14:38:30 +02:00
4de8122920 More code simplification 2018-09-04 14:34:14 +02:00
21d2257be0 Simplified some code 2018-09-04 14:31:08 +02:00
bc4036573c Updated CHANGELOG 2018-09-04 14:30:55 +02:00
87cf1e12fa Prevent KeyError when accessing ps['flamenco_manager'] 2018-09-04 14:11:15 +02:00
b35d7bc5f3 Made the add-on more compatible with 2.80 and 2.79 2018-09-04 13:48:44 +02:00
973dafcc3a Store some flamenco job preferences on a per-manager basis
Managers often require distinct input and output path, which can now
be saved and loaded from the User Preferences, as well as in the
Flamenco panel.
2018-07-25 15:01:39 +02:00
62d16fff35 Display only Flamenco Managers linked to the current project 2018-07-24 15:44:12 +02:00
ed3de414ef Bumped version to 1.8.99999 2018-07-12 11:54:38 +02:00
b0a03c81f5 Flamenco: allow jobs to be created in 'paused' state. 2018-07-12 11:54:13 +02:00
99f0764986 More efficient removal of Flamenco-specific Scene properties 2018-07-12 11:54:01 +02:00
f9c2dda9fa Fixed problem with relative project paths 2018-07-12 11:53:30 +02:00
0a7dea568a Bundle BAT 0.4 2018-07-10 16:05:45 +02:00
40c31e8be2 Upgrade blender-asset-tracer 0.2-dev → 0.3 2018-07-03 15:12:36 +02:00
394395a7f5 Update bl_info to mark compatibility with Blender 2.80+
The add-on will still work on Blender 2.77a+; this change is required for
Blender 2.80 to load the add-on.
2018-07-03 12:32:50 +02:00
f1478bf3d9 Bumped version to 1.8.9999 2018-06-01 17:33:24 +02:00
2fce27f8cb Made the add-on not immediately crash on Blender 2.8 2018-06-01 17:22:49 +02:00
59e6491110 Bumped BAT required version to 0.2 2018-05-08 12:52:43 +02:00
afca7abe18 Bundle development version of BAT for now
This makes testing a bit easier.
2018-03-26 17:22:38 +02:00
4aae107396 Support colour strips as Attract shots 2018-03-22 16:25:35 +01:00
096a5f5803 Updated changelog 2018-03-22 16:22:44 +01:00
79dc5c91f7 Gracefully handle download errors in texture browser 2018-03-22 14:21:09 +01:00
0a99b9e22e Save jobinfo.json to output directory
Previously it would be saved in the same directory as the blend file, which
may be deeply nested in a directory structure. Now it's saved at the top
of the BAT pack.
2018-03-21 16:05:20 +01:00
0452fd845b Fix for some threading issue 2018-03-21 15:57:45 +01:00
c0a8602e17 Get the FLAMENCO_OT_copy_files operator up to par with the rest 2018-03-21 15:57:45 +01:00
10c51b3af5 For development, require latest version of BAT 2018-03-21 15:34:56 +01:00
0be5c16926 Avoid TypeError when project-specific Flamenco Manager cannot be found 2018-03-16 14:13:29 +01:00
4158c4eed5 Require BAT 0.1 2018-03-16 13:47:24 +01:00
de4a93de98 Bumped version to 1.8.999 to indicate '1.9-dev'
Maybe this will even become 2.0 eventually.
2018-03-16 13:46:48 +01:00
331e9e6ca0 Don't show stack trace when BAT Pack was aborted 2018-03-16 12:41:09 +01:00
1d81f4bc38 Some code unindentation 2018-03-16 12:40:05 +01:00
5f58f8b6f7 Set status done 'DONE' after done 2018-03-16 12:26:16 +01:00
164f65f30c Allow aborting a running BAT Pack operation 2018-03-16 12:15:53 +01:00
b82bc14fcf Flamenco: Reporting BAT Pack process on the UI 2018-03-15 18:01:04 +01:00
9e5dcd0b55 Replaced BAM with BAT
Blender Asset Tracer, or BAT, is a newly written replacement for BAM,
with a nicer API.
2018-03-15 12:36:05 +01:00
531ddad8f5 Formatting 2018-02-21 13:51:01 +01:00
7e0dd0384d Simplified wheel downloading 2018-02-21 13:50:55 +01:00
da0c8dd944 Marked 1.8 as released 2018-02-15 17:42:00 +01:00
8065ab88a4 Bumped version to 1.8.0 2018-01-03 14:09:01 +01:00
6baf43e53b Fix KeyError when browsing HDRIs 2018-01-02 17:24:03 +01:00
f1fa273370 Updated changelog 2018-01-02 16:48:47 +01:00
bf96638c88 Updated changelog 2018-01-02 16:44:33 +01:00
bc8a985228 Added button to open a Cloud project in webbrowser. 2018-01-02 16:44:29 +01:00
ba14c33b6d Store project-specific settings in the preferences.
This stores project-specific settings, such as filesystem paths, for each
project, and restores those settings when the project is selected again.
Does not touch settings that haven't been set for the newly selected
project.
2018-01-02 16:42:37 +01:00
0a7e7195a2 Changed default Flamenco path to tempfile.gettempdir()
The previous defaults were very Blender Institute specific.
2018-01-02 15:37:38 +01:00
ecab0f6163 Upgraded cryptography package version + its dependencies
This was required to get the package to build on Kubuntu 17.10.
2018-01-02 15:36:19 +01:00
3c91ccced6 Texture Browser: use DPI from user preferences 2018-01-02 15:10:27 +01:00
c9ed6c7d23 Texture browser: show which map types are available in GUI 2018-01-02 14:53:30 +01:00
5fa01daf9e Revisit previous path when re-opening texture browser. 2018-01-02 14:11:30 +01:00
77664fb6d7 Distinguish between 'renew' and 'join' in messages & URL to open. 2018-01-02 14:02:17 +01:00
45cffc5365 Bumped version to 1.7.99 (surrogate for 1.8-dev) 2018-01-02 14:02:17 +01:00
fb5433d473 Bumped version to 1.7.5 2017-10-06 12:39:38 +02:00
a17fe45712 Allow overriding the render output path on a per-scene basis. 2017-10-06 12:39:18 +02:00
1bfba64bdc Formatting 2017-10-06 12:38:17 +02:00
cdb4bf4f4f Renamed 'Job File Path' to 'Job Storage Path' so it's more explicit. 2017-10-06 12:37:44 +02:00
15254b8951 Sorting the project list in user prefs alphabetically 2017-10-06 12:35:25 +02:00
3ed5f2c187 Bumped version to 1.7.4 2017-09-05 11:26:52 +02:00
0be3bf7f49 Fixed unit test, it still mocked sys.platform
We now use platform.system() to detect the platform.
2017-09-05 11:25:25 +02:00
f207e14664 Added link to changelog 2017-09-05 11:16:22 +02:00
9932003400 Fix T48852: screenshot always shows "Communicating with Blender Cloud" 2017-09-05 11:16:17 +02:00
e7035e6f0c Updated changelog 2017-09-05 11:11:31 +02:00
014a36d24e Fix T52621: class name collision upon add-on registration
This is checked since Blender 2.79.
2017-09-05 11:07:33 +02:00
068451a7aa Mark 1.7.3 as released in changelog 2017-09-05 11:06:28 +02:00
56fb1ec3df Bumped version to 1.7.3 2017-08-08 12:46:07 +02:00
e93094cb88 Default to scene frame range when no frame range is given. 2017-07-03 11:09:31 +02:00
33718a1a35 Removed test print statement 2017-07-03 11:09:00 +02:00
db82dbe730 Updated changelog 2017-07-03 09:16:01 +02:00
8d405330ee Better platform detection.
The sys.platform string is 'win32' even on 64-bit Windows. Furthermore,
we expect 'windows', not 'win32'. platform.system().lower() gives us this.
2017-07-03 09:14:27 +02:00
66ddc7b47b Fixed issue running BAM on Windows.
I found this solution in a Django bug report:
 https://code.djangoproject.com/ticket/24160
2017-07-03 09:13:49 +02:00
2fa8cb4054 Refuse to render on Flamenco before blend file is saved at least once.
The file should have a location on the filesystem before BAM can pick it up.
2017-07-03 08:41:26 +02:00
e7b5c75046 Bumped version to 1.7.2 2017-06-22 15:09:39 +02:00
1d93bd9e5e Allow reloading of the Flamenco module with F8 2017-06-22 15:08:30 +02:00
ac2d0c033c Added missing parameter to function call 2017-06-22 15:08:30 +02:00
61fa63eb1d Compatibility fixes for Blender 2.78c
Blender 2.78c is shipped with a version of the io_blend_utils module that
doesn't have a `pythonpath()` function yet, and that's bundled with an
older version of BAM. To work around this, we ship BAM as wheel, and detect
whether this version is needed to run.

As an added bonus, Blender 2.78c can now also use the file exclude filter
for Flamenco. The `bam_supports_exclude_option()` function is thus no
longer necessary.
2017-06-22 15:08:30 +02:00
7022412889 Allow Pillar server URL overriding from environment 2017-06-14 12:56:34 +02:00
b4f71745b0 Released 1.7.1 2017-06-13 14:50:56 +02:00
1d41fce1ae Updated changelog 2017-06-13 14:47:33 +02:00
e636fde4ce Bumped version to 1.7.1 2017-06-13 14:42:20 +02:00
82a9dc5226 Two-stage timeouts for Pillar calls 2017-06-13 14:39:29 +02:00
1f40915ac8 Added some debug log 2017-06-13 14:39:29 +02:00
32693c0f64 Fixed erroneous return type declaration 2017-06-13 14:39:29 +02:00
c38748eb05 Shorten URLs in debug logging 2017-06-13 14:39:29 +02:00
ac85bea111 Some asyncio tweaks. 2017-06-13 14:39:29 +02:00
7b5613ce77 Fixed issue with multiple asyncio loops on Windows.
The biggest issue was the construction of an asyncio.Semaphore() while the
default loop is alive, and then creating a new loop on win32.

I've also taken the opportunity to explicitly pass our loop to some calls,
rather than expecting them to use the correct one automagically, and added
some more explicit timeout handling to the semaphore usage.
2017-06-13 13:35:05 +02:00
ec5f317dac Bumped version to 1.7.0 2017-06-09 11:04:49 +02:00
a51f61d9b5 Added translation: path → path replacement variable 2017-06-09 10:52:56 +02:00
13bc9a89c8 Updated changelog 2017-05-03 15:33:52 +02:00
996b722813 Fixed bug where a symlinked project path caused an issue
Blender would report that the blend file wasn't in the project path, even
though it was. This was caused by resolving symlinks in the project path,
but not in the blendfile path.
2017-05-03 15:33:22 +02:00
e7f2567bfc Clear a LRU cache when (de)activating Flamenco 2017-05-03 15:32:32 +02:00
ff8e71c542 Fixed reloading after upgrading from 1.4.4. 2017-05-03 12:12:58 +02:00
543da5c8d8 Flamenco exclusion filter requires BAM 1.1.7; this is now checked
A warning is shown in the GUI when a BAM version that's too old is used
(instead of simply crashing when an exclusion filter was specified).
2017-05-02 18:48:54 +02:00
01ae0f5f54 Bumped version to 1.6.4 2017-04-21 18:16:14 +02:00
1e80446870 Added file exclusion filter for Flamenco.
A filter like "*.abc;*.mkv;*.mov" can be used to prevent certain files
from being copied to the job storage directory. Requires a Blender that is
bundled with BAM 1.1.7 or newer.
2017-04-21 18:15:59 +02:00
8d5c97931e Fixed capitalisation of label 2017-03-21 14:26:08 +01:00
1a0c00b87a Removed my name from changelog entry 2017-03-21 14:20:15 +01:00
32befc51f8 Include CHANGELOG.md as data file in distribution 2017-03-21 14:18:58 +01:00
06126862d4 Bumped version to 1.6.3 2017-03-21 14:16:37 +01:00
7b8713881e update_version.sh now prints example commit & tag commands 2017-03-21 14:16:29 +01:00
7c65851b75 Allow version passed to update_version.sh to start with "version-" prefix
This allows you to copy-paste a tag and edit it for the new version.
2017-03-21 14:16:07 +01:00
ec72091268 Added changelog, which will contain user-relevant changes. 2017-03-21 14:13:56 +01:00
cf7adb065f Local project path is used by both Attract and Flamenco
It's now shown whenever the project is set up for either one.
2017-03-21 14:06:45 +01:00
74220e4fc4 Bumped version to 1.6.2 2017-03-17 15:39:53 +01:00
0ebd4435e5 Flamenco: when opening non-existing file path, open parent instead
This is very useful for opening render output directories from Blender,
as those will only exist after rendering has started.
2017-03-17 15:08:50 +01:00
c24501661e Fix T50954: Improve Blender Cloud add-on project selector
Attract and Flamenco features are (de)activated based on the extensions
enabled on the selected project. As a result, anyone can use the add-on
again, without seeing Attract or Flamenco things they can't use.
2017-03-17 15:08:09 +01:00
5b77ae50a1 Bumped version to 1.6.1 2017-03-07 11:01:11 +01:00
74958cf217 Show error in GUI when Blender Cloud is unreachable 2017-03-07 11:00:42 +01:00
5026dfc441 Fixed sample count when using branched path tracing
Thanks to dr. Sharybin for the patch.
2017-03-07 11:00:23 +01:00
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
31 changed files with 3338 additions and 618 deletions

158
CHANGELOG.md Normal file
View File

@@ -0,0 +1,158 @@
# Blender Cloud changelog
## Version 1.11.1 (2019-01-04)
- Bundled missing Texture Browser icons.
## Version 1.11.0 (2019-01-04)
- Texture Browser now works on Blender 2.8.
- Blender Sync: Fixed compatibility issue with Blender 2.8.
## Version 1.10.0 (2019-01-02)
- Bundles Blender-Asset-Tracer 0.8.
- Fix crashing Blender when running in background mode (e.g. without GUI).
- Flamenco: Include extra job parameters to allow for encoding a video at the end of a render
job that produced an image sequence.
- Flamenco: Compress all blend files, and not just the one we save from Blender.
- Flamenco: Store more info in the `jobinfo.json` file. This is mostly useful for debugging issues
on the render farm, as now things like the exclusion filter and Manager settings are logged too.
- Flamenco: Allow BAT-packing of only those assets that are referred to by relative path (e.g.
a path starting with `//`). Assets with an absolute path are ignored, and assumed to be reachable
at the same path by the Workers.
- Flamenco: Added 'blender-video-chunks' job type, meant for rendering the edit of a film from the
VSE. This job type requires that the file is configured for rendering to Matroska video
files.
Audio is only extracted when there is an audio codec configured. This is a bit arbitrary, but it's
at least a way to tell whether the artist is considering that there is audio of any relevance in
the current blend file.
## Version 1.9.4 (2018-11-01)
- Fixed Python 3.6 and Blender 2.79b incompatibilities accidentally introduced in 1.9.3.
## Version 1.9.3 (2018-10-30)
- Fix drawing of Attract strips in the VSE on Blender 2.8.
## Version 1.9.2 (2018-09-17)
- No changes, just a different filename to force a refresh on our
hosting platform.
## Version 1.9.1 (2018-09-17)
- Fix issue with Python 3.7, which is used by current daily builds of Blender.
## Version 1.9 (2018-09-05)
- Last version to support Blender versions before 2.80!
- Replace BAM with BAT🦇.
- Don't crash the texture browser when an invalid texture is seen.
- Support colour strips as Attract shots.
- Flamenco: allow jobs to be created in 'paused' state.
- Flamenco: only show Flamenco Managers that are linked to the currently selected project.
## Version 1.8 (2018-01-03)
- Distinguish between 'please subscribe' (to get a new subscription) and 'please renew' (to renew an
existing subscription).
- When re-opening the Texture Browser it now opens in the same folder as where it was when closed.
- In the texture browser, draw the components of the texture (i.e. which map types are available),
such as 'bump, normal, specular'.
- Use Interface Scale setting from user preferences to draw the Texture Browser text.
- Store project-specific settings in the preferences, such as filesystem paths, for each project,
and restore those settings when the project is selected again. Does not touch settings that
haven't been set for the newly selected project. These settings are only saved when a setting
is updated, so to save your current settings need to update a single setting; this saves all
settings for the project.
- Added button in the User Preferences to open a Cloud project in your webbrowser.
## Version 1.7.5 (2017-10-06)
- Sorting the project list alphabetically.
- Renamed 'Job File Path' to 'Job Storage Path' so it's more explicit.
- Allow overriding the render output path on a per-scene basis.
## Version 1.7.4 (2017-09-05)
- Fix [T52621](https://developer.blender.org/T52621): Fixed class name collision upon add-on
registration. This is checked since Blender 2.79.
- Fix [T48852](https://developer.blender.org/T48852): Screenshot no longer shows "Communicating with
Blender Cloud".
## Version 1.7.3 (2017-08-08)
- Default to scene frame range when no frame range is given.
- Refuse to render on Flamenco before blend file is saved at least once.
- Fixed some Windows-specific issues.
## Version 1.7.2 (2017-06-22)
- Fixed compatibility with Blender 2.78c.
## Version 1.7.1 (2017-06-13)
- Fixed asyncio issues on Windows
## Version 1.7.0 (2017-06-09)
- Fixed reloading after upgrading from 1.4.4 (our last public release).
- Fixed bug handling a symlinked project path.
- Added support for Manager-defined path replacement variables.
## Version 1.6.4 (2017-04-21)
- Added file exclusion filter for Flamenco. A filter like `*.abc;*.mkv;*.mov` can be
used to prevent certain files from being copied to the job storage directory.
Requires a Blender that is bundled with BAM 1.1.7 or newer.
## Version 1.6.3 (2017-03-21)
- Fixed bug where local project path wasn't shown for projects only set up for Flamenco
(and not Attract).
- Added this CHANGELOG.md file, which will contain user-relevant changes.
## Version 1.6.2 (2017-03-17)
- Flamenco: when opening non-existing file path, open parent instead
- Fix T50954: Improve Blender Cloud add-on project selector
## Version 1.6.1 (2017-03-07)
- Show error in GUI when Blender Cloud is unreachable
- Fixed sample count when using branched path tracing
## Version 1.6.0 (2017-02-14)
- Default to frame chunk size of 1 (instead of 10).
- Turn off "use overwrite" and "use placeholder" for Flamenco blend files.
- Fixed bugs when blendfile is outside the project directory
## Older versions
For the history of older versions, please refer to the
[Git history](https://developer.blender.org/diffusion/BCA/)

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

@@ -21,8 +21,8 @@
bl_info = {
'name': 'Blender Cloud',
"author": "Sybren A. Stüvel, Francesco Siddi, Inês Almeida, Antony Riakiotakis",
'version': (1, 5, 2),
'blender': (2, 77, 0),
'version': (1, 11, 1),
'blender': (2, 80, 0),
'location': 'Addon Preferences panel, and Ctrl+Shift+Alt+A anywhere for texture browser',
'description': 'Texture library browser and Blender Sync. Requires the Blender ID addon '
'and Blender 2.77a or newer.',
@@ -65,32 +65,45 @@ def register():
def reload_mod(name):
modname = '%s.%s' % (__name__, name)
module = importlib.reload(sys.modules[modname])
sys.modules[modname] = module
return module
try:
old_module = sys.modules[modname]
except KeyError:
# Wasn't loaded before -- can happen after an upgrade.
new_module = importlib.import_module(modname)
else:
new_module = importlib.reload(old_module)
sys.modules[modname] = new_module
return new_module
reload_mod('blendfile')
reload_mod('home_project')
reload_mod('utils')
reload_mod('pillar')
blender = reload_mod('blender')
async_loop = reload_mod('async_loop')
flamenco = reload_mod('flamenco')
attract = reload_mod('attract')
texture_browser = reload_mod('texture_browser')
settings_sync = reload_mod('settings_sync')
image_sharing = reload_mod('image_sharing')
attract = reload_mod('attract')
blender = reload_mod('blender')
project_specific = reload_mod('project_specific')
else:
from . import (blender, texture_browser, async_loop, settings_sync, blendfile, home_project,
image_sharing, attract)
image_sharing, attract, flamenco, project_specific)
async_loop.setup_asyncio_executor()
async_loop.register()
flamenco.register()
attract.register()
texture_browser.register()
blender.register()
settings_sync.register()
image_sharing.register()
attract.register()
blender.register()
project_specific.handle_project_update()
def _monkey_patch_requests():
@@ -111,7 +124,8 @@ def _monkey_patch_requests():
def unregister():
from . import blender, texture_browser, async_loop, settings_sync, image_sharing, attract
from . import (blender, texture_browser, async_loop, settings_sync, image_sharing, attract,
flamenco)
image_sharing.unregister()
attract.unregister()
@@ -119,3 +133,4 @@ def unregister():
blender.unregister()
texture_browser.unregister()
async_loop.unregister()
flamenco.unregister()

View File

@@ -511,7 +511,7 @@ if system == "win32":
_get_win_folder = _get_win_folder_with_pywin32
except ImportError:
try:
from ctypes import windll
from ctypes import windll # type: ignore
_get_win_folder = _get_win_folder_with_ctypes
except ImportError:
try:

View File

@@ -23,6 +23,7 @@ import traceback
import concurrent.futures
import logging
import gc
import typing
import bpy
@@ -33,18 +34,28 @@ _loop_kicking_operator_running = False
def setup_asyncio_executor():
"""Sets up AsyncIO to run on a single thread.
"""Sets up AsyncIO to run properly on each platform."""
This ensures that only one Pillar HTTP call is performed at the same time. Other
calls that could be performed in parallel are queued, and thus we can
reliably cancel them.
"""
import sys
executor = concurrent.futures.ThreadPoolExecutor()
loop = asyncio.get_event_loop()
if sys.platform == 'win32':
asyncio.get_event_loop().close()
# On Windows, the default event loop is SelectorEventLoop, which does
# not support subprocesses. ProactorEventLoop should be used instead.
# Source: https://docs.python.org/3/library/asyncio-subprocess.html
loop = asyncio.ProactorEventLoop()
asyncio.set_event_loop(loop)
else:
loop = asyncio.get_event_loop()
executor = concurrent.futures.ThreadPoolExecutor(max_workers=10)
loop.set_default_executor(executor)
# loop.set_debug(True)
from . import pillar
# No more than this many Pillar calls should be made simultaneously
pillar.pillar_semaphore = asyncio.Semaphore(3, loop=loop)
def kick_async_loop(*args) -> bool:
"""Performs a single iteration of the asyncio event loop.
@@ -143,7 +154,7 @@ class AsyncLoopModalOperator(bpy.types.Operator):
_loop_kicking_operator_running = True
wm = context.window_manager
self.timer = wm.event_timer_add(0.00001, context.window)
self.timer = wm.event_timer_add(0.00001, window=context.window)
return {'RUNNING_MODAL'}
@@ -182,7 +193,7 @@ class AsyncModalOperatorMixin:
def invoke(self, context, event):
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, window=context.window)
self.log.info('Starting')
self._new_async_task(self.async_execute(context))
@@ -228,7 +239,7 @@ class AsyncModalOperatorMixin:
self._stop_async_task()
context.window_manager.event_timer_remove(self.timer)
def _new_async_task(self, async_task: asyncio.coroutine, future: asyncio.Future = None):
def _new_async_task(self, async_task: typing.Coroutine, future: asyncio.Future = None):
"""Stops the currently running async task, and starts another one."""
self.log.debug('Setting up a new task %r, so any existing task must be stopped', async_task)

View File

@@ -46,9 +46,15 @@ if "bpy" in locals():
draw = importlib.reload(draw)
pillar = importlib.reload(pillar)
async_loop = importlib.reload(async_loop)
blender = importlib.reload(blender)
else:
from . import draw
from .. import pillar, async_loop
import bpy
if bpy.app.version < (2, 80):
from . import draw_27 as draw
else:
from . import draw
from .. import pillar, async_loop, blender
import bpy
import pillarsdk
@@ -60,6 +66,9 @@ from bpy.types import Operator, Panel, AddonPreferences
log = logging.getLogger(__name__)
# Global flag used to determine whether panels etc. can be drawn.
attract_is_active = False
def active_strip(context):
try:
@@ -139,6 +148,9 @@ def shot_id_use(strips):
def compute_strip_conflicts(scene):
"""Sets the strip property atc_object_id_conflict for each strip."""
if not attract_is_active:
return
if not scene or not scene.sequence_editor or not scene.sequence_editor.sequences_all:
return
@@ -161,10 +173,17 @@ def scene_update_post_handler(scene):
compute_strip_conflicts(scene)
class ToolsPanel(Panel):
class AttractPollMixin:
@classmethod
def poll(cls, context):
return attract_is_active
class ATTRACT_PT_tools(AttractPollMixin, Panel):
bl_label = 'Attract'
bl_space_type = 'SEQUENCE_EDITOR'
bl_region_type = 'UI'
bl_category = 'Strip'
def draw_header(self, context):
strip = active_strip(context)
@@ -174,71 +193,74 @@ class ToolsPanel(Panel):
def draw(self, context):
strip = active_strip(context)
layout = self.layout
strip_types = {'MOVIE', 'IMAGE', 'META'}
strip_types = {'MOVIE', 'IMAGE', 'META', 'COLOR'}
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')
self._draw_attractstrip_buttons(context, strip)
elif context.selected_sequences:
if len(context.selected_sequences) > 1:
noun = 'Selected Strips'
else:
noun = 'This Strip'
layout.operator(AttractShotSubmitSelected.bl_idname,
layout.operator(ATTRACT_OT_submit_selected.bl_idname,
text='Submit %s as New Shot' % noun)
layout.operator('attract.shot_relink')
else:
layout.operator(ATTRACT_OT_submit_all.bl_idname)
layout.operator(ATTRACT_OT_project_open_in_browser.bl_idname, icon='WORLD')
def _draw_attractstrip_buttons(self, context, strip):
"""Draw buttons when selected strips are Attract shots."""
layout = self.layout
selshots = list(selected_shots(context))
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(text='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(ATTRACT_OT_shot_fetch_update.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(**blender.factor(0.6), align=True)
dangerous_sub.operator('attract.strip_unlink',
text='Unlink %s' % noun,
icon='PANEL_CLOSE')
dangerous_sub.operator(ATTRACT_OT_shot_delete.bl_idname,
text='Delete %s' % noun,
icon='CANCEL')
class AttractOperatorMixin:
class AttractOperatorMixin(AttractPollMixin):
"""Mix-in class for all Attract operators."""
def _project_needs_setup_error(self):
@@ -261,7 +283,7 @@ class AttractOperatorMixin:
from .. import pillar, blender
prefs = blender.preferences()
project = self.find_project(prefs.attract_project.project)
project = self.find_project(prefs.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
@@ -295,7 +317,7 @@ class AttractOperatorMixin:
'cut_in_timeline_in_frames': strip.frame_final_start},
'order': 0,
'node_type': 'attract_shot',
'project': blender.preferences().attract_project.project,
'project': blender.preferences().project.project,
'user': user_uuid}
# Create a Node item with the attract API
@@ -366,14 +388,14 @@ class AttractOperatorMixin:
draw.tag_redraw_all_sequencer_editors()
class AttractShotFetchUpdate(AttractOperatorMixin, Operator):
class ATTRACT_OT_shot_fetch_update(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))
return AttractOperatorMixin.poll(context) and any(selected_shots(context))
def execute(self, context):
for strip in selected_shots(context):
@@ -385,7 +407,7 @@ class AttractShotFetchUpdate(AttractOperatorMixin, Operator):
return {'FINISHED'}
class AttractShotRelink(AttractShotFetchUpdate):
class ATTRACT_OT_shot_relink(AttractOperatorMixin, Operator):
bl_idname = "attract.shot_relink"
bl_label = "Relink With Attract"
@@ -393,6 +415,9 @@ class AttractShotRelink(AttractShotFetchUpdate):
@classmethod
def poll(cls, context):
if not AttractOperatorMixin.poll(context):
return False
strip = active_strip(context)
return strip is not None and not getattr(strip, 'atc_object_id', None)
@@ -433,7 +458,8 @@ class ATTRACT_OT_shot_open_in_browser(AttractOperatorMixin, Operator):
@classmethod
def poll(cls, context):
return bool(context.selected_sequences and active_strip(context))
return AttractOperatorMixin.poll(context) and \
bool(context.selected_sequences and active_strip(context))
def execute(self, context):
from ..blender import PILLAR_WEB_SERVER_URL
@@ -450,7 +476,7 @@ class ATTRACT_OT_shot_open_in_browser(AttractOperatorMixin, Operator):
return {'FINISHED'}
class AttractShotDelete(AttractOperatorMixin, Operator):
class ATTRACT_OT_shot_delete(AttractOperatorMixin, Operator):
bl_idname = 'attract.shot_delete'
bl_label = 'Delete Shot'
bl_description = 'Remove this shot from Attract'
@@ -459,7 +485,8 @@ class AttractShotDelete(AttractOperatorMixin, Operator):
@classmethod
def poll(cls, context):
return bool(context.selected_sequences)
return AttractOperatorMixin.poll(context) and \
bool(context.selected_sequences)
def execute(self, context):
from .. import pillar
@@ -504,14 +531,15 @@ class AttractShotDelete(AttractOperatorMixin, Operator):
col.prop(self, 'confirm', text="I hereby confirm: delete %s from The Edit." % noun)
class AttractStripUnlink(AttractOperatorMixin, Operator):
class ATTRACT_OT_strip_unlink(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)
return AttractOperatorMixin.poll(context) and \
bool(context.selected_sequences)
def execute(self, context):
unlinked_ids = set()
@@ -547,14 +575,15 @@ class AttractStripUnlink(AttractOperatorMixin, Operator):
return {'FINISHED'}
class AttractShotSubmitSelected(AttractOperatorMixin, Operator):
class ATTRACT_OT_submit_selected(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)
return AttractOperatorMixin.poll(context) and \
bool(context.selected_sequences)
def execute(self, context):
# Check that the project is set up for Attract.
@@ -610,7 +639,8 @@ class ATTRACT_OT_open_meta_blendfile(AttractOperatorMixin, Operator):
@classmethod
def poll(cls, context):
return bool(any(cls.filename_from_metadata(s) for s in context.selected_sequences))
return AttractOperatorMixin.poll(context) and \
bool(any(cls.filename_from_metadata(s) for s in context.selected_sequences))
@staticmethod
def filename_from_metadata(strip):
@@ -677,7 +707,7 @@ class ATTRACT_OT_make_shot_thumbnail(AttractOperatorMixin,
@classmethod
def poll(cls, context):
return bool(context.selected_sequences)
return AttractOperatorMixin.poll(context) and bool(context.selected_sequences)
@contextlib.contextmanager
def thumbnail_render_settings(self, context, thumbnail_width=512):
@@ -843,7 +873,7 @@ class ATTRACT_OT_make_shot_thumbnail(AttractOperatorMixin,
from .. import blender
prefs = blender.preferences()
project = self.find_project(prefs.attract_project.project)
project = self.find_project(prefs.project.project)
self.log.info('Uploading file %s', filename)
resp = await pillar.pillar_call(
@@ -873,7 +903,8 @@ class ATTRACT_OT_copy_id_to_clipboard(AttractOperatorMixin, Operator):
@classmethod
def poll(cls, context):
return bool(context.selected_sequences and active_strip(context))
return AttractOperatorMixin.poll(context) and \
bool(context.selected_sequences and active_strip(context))
def execute(self, context):
strip = active_strip(context)
@@ -884,6 +915,37 @@ class ATTRACT_OT_copy_id_to_clipboard(AttractOperatorMixin, Operator):
return {'FINISHED'}
class ATTRACT_OT_project_open_in_browser(Operator):
bl_idname = 'attract.project_open_in_browser'
bl_label = 'Open Project in Browser'
bl_description = 'Opens a webbrowser to show the project in Attract'
project_id = bpy.props.StringProperty(name='Project ID', default='')
def execute(self, context):
import webbrowser
import urllib.parse
import pillarsdk
from ..pillar import sync_call
from ..blender import PILLAR_WEB_SERVER_URL, preferences
if not self.project_id:
self.project_id = preferences().project.project
project = sync_call(pillarsdk.Project.find, self.project_id, {'projection': {'url': True}})
if log.isEnabledFor(logging.DEBUG):
import pprint
log.debug('found project: %s', pprint.pformat(project.to_dict()))
url = urllib.parse.urljoin(PILLAR_WEB_SERVER_URL, 'attract/' + project.url)
webbrowser.open_new_tab(url)
self.report({'INFO'}, 'Opened a browser at %s' % url)
return {'FINISHED'}
def draw_strip_movie_meta(self, context):
strip = active_strip(context)
if not strip:
@@ -897,12 +959,45 @@ def draw_strip_movie_meta(self, context):
row = box.row(align=True)
fname = meta.get('BLEND_FILE', None) or None
if fname:
row.label('Original Blendfile: %s' % fname)
row.label(text='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))
box.label(text='Original Frame Range: %s-%s' % (sfra, efra))
def activate():
global attract_is_active
log.info('Activating Attract')
attract_is_active = True
# TODO: properly fix 2.8 compatibility; this is just a workaround.
if hasattr(bpy.app.handlers, 'scene_update_post'):
bpy.app.handlers.scene_update_post.append(scene_update_post_handler)
draw.callback_enable()
def deactivate():
global attract_is_active
log.info('Deactivating Attract')
attract_is_active = False
draw.callback_disable()
# TODO: properly fix 2.8 compatibility; this is just a workaround.
if hasattr(bpy.app.handlers, 'scene_update_post'):
try:
bpy.app.handlers.scene_update_post.remove(scene_update_post_handler)
except ValueError:
# This is thrown when scene_update_post_handler does not exist in the handler list.
pass
_rna_classes = [cls for cls in locals().values()
if isinstance(cls, type) and cls.__name__.startswith('ATTRACT')]
log.info('RNA classes:\n%s', '\n'.join([repr(cls) for cls in _rna_classes]))
def register():
@@ -930,26 +1025,18 @@ def register():
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()
for cls in _rna_classes:
bpy.utils.register_class(cls)
def unregister():
draw.callback_disable()
bpy.app.handlers.scene_update_post.remove(scene_update_post_handler)
bpy.utils.unregister_module(__name__)
deactivate()
for cls in _rna_classes:
try:
bpy.utils.unregister_class(cls)
except RuntimeError:
log.warning('Unable to unregister class %r, probably already unregistered', cls)
del bpy.types.Sequence.atc_is_synced
del bpy.types.Sequence.atc_object_id
del bpy.types.Sequence.atc_object_id_conflict

View File

@@ -18,9 +18,12 @@
# <pep8 compliant>
import bpy
import logging
import collections
import typing
import bpy
import bgl
import gpu
log = logging.getLogger(__name__)
@@ -34,10 +37,74 @@ strip_status_colour = {
'todo': (1.0, 0.5019607843137255, 0.5019607843137255)
}
CONFLICT_COLOUR = (0.576, 0.118, 0.035) # RGB tuple
CONFLICT_COLOUR = (0.576, 0.118, 0.035, 1.0) # RGBA tuple
gpu_vertex_shader = '''
uniform mat4 ModelViewProjectionMatrix;
layout (location = 0) in vec2 pos;
layout (location = 1) in vec4 color;
out vec4 lineColor; // output to the fragment shader
void main()
{
gl_Position = ModelViewProjectionMatrix * vec4(pos.x, pos.y, 0.0, 1.0);
lineColor = color;
}
'''
gpu_fragment_shader = '''
out vec4 fragColor;
in vec4 lineColor;
void main()
{
fragColor = lineColor;
}
'''
Float2 = typing.Tuple[float, float]
Float3 = typing.Tuple[float, float, float]
Float4 = typing.Tuple[float, float, float, float]
def get_strip_rectf(strip):
class AttractLineDrawer:
def __init__(self):
self._format = gpu.types.GPUVertFormat()
self._pos_id = self._format.attr_add(
id="pos",
comp_type="F32",
len=2,
fetch_mode="FLOAT")
self._color_id = self._format.attr_add(
id="color",
comp_type="F32",
len=4,
fetch_mode="FLOAT")
self.shader = gpu.types.GPUShader(gpu_vertex_shader, gpu_fragment_shader)
def draw(self,
coords: typing.List[Float2],
colors: typing.List[Float4]):
if not coords:
return
bgl.glEnable(bgl.GL_BLEND)
bgl.glLineWidth(2.0)
vbo = gpu.types.GPUVertBuf(len=len(coords), format=self._format)
vbo.attr_fill(id=self._pos_id, data=coords)
vbo.attr_fill(id=self._color_id, data=colors)
batch = gpu.types.GPUBatch(type="LINES", buf=vbo)
batch.program_set(self.shader)
batch.draw()
def get_strip_rectf(strip) -> Float4:
# Get x and y in terms of the grid's frames and channels
x1 = strip.frame_final_start
x2 = strip.frame_final_end
@@ -47,59 +114,56 @@ def get_strip_rectf(strip):
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
def underline_in_strip(strip_coords: Float4,
pixel_size_x: float,
color: Float4,
out_coords: typing.List[Float2],
out_colors: typing.List[Float4]):
# Strip coords
s_x1, s_y1, s_x2, s_y2 = strip_coords
# be careful not to draw over the current frame line
cf_x = context.scene.frame_current_final
cf_x = bpy.context.scene.frame_current_final
bgl.glPushAttrib(bgl.GL_COLOR_BUFFER_BIT | bgl.GL_LINE_BIT)
# TODO(Sybren): figure out how to pass one colour per line,
# instead of one colour per vertex.
out_coords.append((s_x1, s_y1))
out_colors.append(color)
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)
# Bad luck, the line passes our strip, so draw two lines.
out_coords.append((cf_x - pixel_size_x, s_y1))
out_colors.append(color)
bgl.glEnd()
bgl.glPopAttrib()
out_coords.append((cf_x + pixel_size_x, s_y1))
out_colors.append(color)
out_coords.append((s_x2, s_y1))
out_colors.append(color)
def draw_strip_conflict(strip_coords, pixel_size_x):
def strip_conflict(strip_coords: Float4,
out_coords: typing.List[Float2],
out_colors: typing.List[Float4]):
"""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)
# TODO(Sybren): draw a rectangle instead of a line.
out_coords.append((s_x1, s_y2))
out_colors.append(CONFLICT_COLOUR)
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()
out_coords.append((s_x2, s_y1))
out_colors.append(CONFLICT_COLOUR)
bgl.glPopAttrib()
out_coords.append((s_x2, s_y2))
out_colors.append(CONFLICT_COLOUR)
out_coords.append((s_x1, s_y1))
out_colors.append(CONFLICT_COLOUR)
def draw_callback_px():
def draw_callback_px(line_drawer: AttractLineDrawer):
context = bpy.context
if not context.scene.sequence_editor:
@@ -115,6 +179,10 @@ def draw_callback_px():
strips = shown_strips(context)
coords = [] # type: typing.List[Float2]
colors = [] # type: typing.List[Float4]
# Collect all the lines (vertex coords + vertex colours) to draw.
for strip in strips:
if not strip.atc_object_id:
continue
@@ -124,7 +192,7 @@ def draw_callback_px():
# 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:
strip_coords[3] < ywin1:
continue
# Draw
@@ -136,9 +204,11 @@ def draw_callback_px():
alpha = 1.0 if strip.atc_is_synced else 0.5
draw_underline_in_strip(strip_coords, pixel_size_x, color + (alpha,))
underline_in_strip(strip_coords, pixel_size_x, color + (alpha,), coords, colors)
if strip.atc_is_synced and strip.atc_object_id_conflict:
draw_strip_conflict(strip_coords, pixel_size_x)
strip_conflict(strip_coords, coords, colors)
line_drawer.draw(coords, colors)
def tag_redraw_all_sequencer_editors():
@@ -162,8 +232,13 @@ def callback_enable():
if cb_handle:
return
# Doing GPU stuff in the background crashes Blender, so let's not.
if bpy.app.background:
return
line_drawer = AttractLineDrawer()
cb_handle[:] = bpy.types.SpaceSequenceEditor.draw_handler_add(
draw_callback_px, (), 'WINDOW', 'POST_VIEW'),
draw_callback_px, (line_drawer,), 'WINDOW', 'POST_VIEW'),
tag_redraw_all_sequencer_editors()
@@ -172,6 +247,11 @@ def callback_disable():
if not cb_handle:
return
bpy.types.SpaceSequenceEditor.draw_handler_remove(cb_handle[0], 'WINDOW')
try:
bpy.types.SpaceSequenceEditor.draw_handler_remove(cb_handle[0], 'WINDOW')
except ValueError:
# Thrown when already removed.
pass
cb_handle.clear()
tag_redraw_all_sequencer_editors()

View File

@@ -0,0 +1,182 @@
# ##### 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
try:
bpy.types.SpaceSequenceEditor.draw_handler_remove(cb_handle[0], 'WINDOW')
except ValueError:
# Thrown when already removed.
pass
cb_handle.clear()
tag_redraw_all_sequencer_editors()

View File

@@ -20,58 +20,42 @@
Separated from __init__.py so that we can import & run from non-Blender environments.
"""
import functools
import logging
import os.path
import tempfile
import bpy
from bpy.types import AddonPreferences, Operator, WindowManager, Scene, PropertyGroup
from bpy.props import StringProperty, EnumProperty, PointerProperty, BoolProperty
from bpy.props import StringProperty, EnumProperty, PointerProperty, BoolProperty, IntProperty
import rna_prop_ui
from . import pillar, async_loop
from . import pillar, async_loop, flamenco, project_specific
from .utils import pyside_cache, redraw
PILLAR_WEB_SERVER_URL = 'https://cloud.blender.org/'
# PILLAR_WEB_SERVER_URL = 'http://pillar-web:5001/'
PILLAR_WEB_SERVER_URL = os.environ.get('BCLOUD_SERVER', 'https://cloud.blender.org/')
PILLAR_SERVER_URL = '%sapi/' % PILLAR_WEB_SERVER_URL
ADDON_NAME = 'blender_cloud'
log = logging.getLogger(__name__)
icons = None
def redraw(self, context):
context.area.tag_redraw()
if bpy.app.version < (2, 80):
SYNC_SELECT_VERSION_ICON = 'DOTSDOWN'
else:
SYNC_SELECT_VERSION_ICON = 'DOWNARROW_HLT'
def pyside_cache(propname):
@functools.lru_cache()
def factor(factor: float) -> dict:
"""Construct keyword argument for UILayout.split().
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
On Blender 2.8 this returns {'factor': factor}, and on earlier Blenders it returns
{'percentage': factor}.
"""
if bpy.app.version < (2, 80, 0):
return {'percentage': factor}
return {'factor': factor}
@pyside_cache('version')
@@ -141,13 +125,37 @@ class SyncStatusProperties(PropertyGroup):
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
projs = preferences().project.available_projects
if not projs:
return [('', 'No projects available in your Blender Cloud', '')]
return [(p['_id'], p['name'], '') for p in projs]
@functools.lru_cache(1)
def project_extensions(project_id) -> set:
"""Returns the extensions the project is enabled for.
At the moment of writing these are 'attract' and 'flamenco'.
"""
log.debug('Finding extensions for project %s', project_id)
# We can't use our @property, since the preferences may be loaded from a
# preferences blend file, in which case it is not constructed from Python code.
available_projects = preferences().project.get('available_projects', [])
if not available_projects:
log.debug('No projects available.')
return set()
proj = next((p for p in available_projects
if p['_id'] == project_id), None)
if proj is None:
log.debug('Project %s not found in available projects.', project_id)
return set()
return set(proj.get('enabled_for', ()))
class BlenderCloudProjectGroup(PropertyGroup):
status = EnumProperty(
items=[
@@ -161,7 +169,9 @@ class BlenderCloudProjectGroup(PropertyGroup):
project = EnumProperty(
items=bcloud_available_projects,
name='Cloud project',
description='Which Blender Cloud project to work with')
description='Which Blender Cloud project to work with',
update=project_specific.handle_project_update
)
# List of projects is stored in 'available_projects' ID property,
# because I don't know how to store a variable list of strings in a proper RNA property.
@@ -172,12 +182,13 @@ class BlenderCloudProjectGroup(PropertyGroup):
@available_projects.setter
def available_projects(self, new_projects):
self['available_projects'] = new_projects
project_specific.handle_project_update()
class BlenderCloudPreferences(AddonPreferences):
bl_idname = ADDON_NAME
# The following two properties are read-only to limit the scope of the
# The following property is read-only to limit the scope of the
# addon and allow for proper testing within this scope.
pillar_server = StringProperty(
name='Blender Cloud Server',
@@ -187,25 +198,75 @@ class BlenderCloudPreferences(AddonPreferences):
)
local_texture_dir = StringProperty(
name='Default Blender Cloud texture storage directory',
name='Default Blender Cloud Texture Storage Directory',
subtype='DIR_PATH',
default='//textures')
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',
# TODO: store project-dependent properties with the project, so that people
# can switch projects and the Attract and Flamenco properties switch with it.
project = PointerProperty(type=BlenderCloudProjectGroup)
cloud_project_local_path = StringProperty(
name='Local Project Path',
description='Local path of your Attract project, used to search for blend files; '
'usually best to set to an absolute path',
subtype='DIR_PATH',
default='//../')
default='//../',
update=project_specific.store,
)
flamenco_manager = PointerProperty(type=flamenco.FlamencoManagerGroup)
flamenco_exclude_filter = StringProperty(
name='File Exclude Filter',
description='Space-separated list of filename filters, like "*.abc *.mkv", to prevent '
'matching files from being packed into the output directory',
default='',
update=project_specific.store,
)
flamenco_job_file_path = StringProperty(
name='Job Storage Path',
description='Path where to store job files, should be accesible for Workers too',
subtype='DIR_PATH',
default=tempfile.gettempdir(),
update=project_specific.store,
)
flamenco_job_output_path = StringProperty(
name='Job Output Path',
description='Path where to store output files, should be accessible for Workers',
subtype='DIR_PATH',
default=tempfile.gettempdir(),
update=project_specific.store,
)
flamenco_job_output_strip_components = IntProperty(
name='Job Output Path Strip Components',
description='The final output path comprises of the job output path, and the blend file '
'path relative to the project with this many path components stripped off '
'the front',
min=0,
default=0,
soft_max=4,
update=project_specific.store,
)
flamenco_relative_only = BoolProperty(
name='Relative Paths Only',
description='When enabled, only assets that are referred to with a relative path are '
'packed, and assets referred to by an absolute path are excluded from the '
'BAT pack. When disabled, all assets are packed.',
default=False,
update=project_specific.store,
)
flamenco_open_browser_after_submit = BoolProperty(
name='Open Browser after Submitting Job',
description='When enabled, Blender will open a webbrowser',
default=True,
)
def draw(self, context):
import textwrap
@@ -220,8 +281,8 @@ class BlenderCloudPreferences(AddonPreferences):
blender_id_profile = None
else:
blender_id_profile = blender_id.get_active_profile()
if blender_id is None:
msg_icon = 'ERROR'
text = 'This add-on requires Blender ID'
help_text = 'Make sure that the Blender ID add-on is installed and activated'
@@ -263,8 +324,8 @@ class BlenderCloudPreferences(AddonPreferences):
bss = context.window_manager.blender_sync_status
bsync_box = layout.box()
bsync_box.enabled = msg_icon != 'ERROR'
row = bsync_box.row().split(percentage=0.33)
row.label('Blender Sync with Blender Cloud', icon_value=icon('CLOUD'))
row = bsync_box.row().split(**factor(0.33))
row.label(text='Blender Sync with Blender Cloud', icon_value=icon('CLOUD'))
icon_for_level = {
'INFO': 'NONE',
@@ -274,7 +335,7 @@ class BlenderCloudPreferences(AddonPreferences):
}
msg_icon = icon_for_level[bss.level] if bss.message else 'NONE'
message_container = row.row()
message_container.label(bss.message, icon=msg_icon)
message_container.label(text=bss.message, icon=msg_icon)
sub = bsync_box.column()
@@ -284,12 +345,20 @@ class BlenderCloudPreferences(AddonPreferences):
# Image Share stuff
share_box = layout.box()
share_box.label('Image Sharing on Blender Cloud', icon_value=icon('CLOUD'))
share_box.label(text='Image Sharing on Blender Cloud', icon_value=icon('CLOUD'))
share_box.prop(self, 'open_browser_after_share')
# Attract stuff
attract_box = layout.box()
self.draw_attract_buttons(attract_box, self.attract_project)
# Project selector
project_box = layout.box()
project_box.enabled = self.project.status in {'NONE', 'IDLE'}
self.draw_project_selector(project_box, self.project)
extensions = project_extensions(self.project.project)
# Flamenco stuff
if 'flamenco' in extensions:
flamenco_box = project_box.column()
self.draw_flamenco_buttons(flamenco_box, self.flamenco_manager, context)
def draw_subscribe_button(self, layout):
layout.operator('pillar.subscribe', icon='WORLD')
@@ -298,7 +367,7 @@ class BlenderCloudPreferences(AddonPreferences):
layout.enabled = bss.status in {'NONE', 'IDLE'}
buttons = layout.column()
row_buttons = buttons.row().split(percentage=0.5)
row_buttons = buttons.row().split(**factor(0.5))
row_push = row_buttons.row()
row_pull = row_buttons.row(align=True)
@@ -321,16 +390,15 @@ class BlenderCloudPreferences(AddonPreferences):
props.blender_version = version
row_pull.operator('pillar.sync',
text='',
icon='DOTSDOWN').action = 'SELECT'
icon=SYNC_SELECT_VERSION_ICON).action = 'SELECT'
else:
row_pull.label('Cloud Sync is running.')
row_pull.label(text='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'))
def draw_project_selector(self, project_box, bcp: BlenderCloudProjectGroup):
project_row = project_box.row(align=True)
project_row.label(text='Project settings', icon_value=icon('CLOUD'))
attract_row.enabled = bcp.status in {'NONE', 'IDLE'}
row_buttons = attract_row.row(align=True)
row_buttons = project_row.row(align=True)
projects = bcp.available_projects
project = bcp.project
@@ -344,10 +412,83 @@ class BlenderCloudPreferences(AddonPreferences):
row_buttons.operator('pillar.projects',
text='',
icon='FILE_REFRESH')
props = row_buttons.operator('pillar.project_open_in_browser',
text='',
icon='WORLD')
props.project_id = project
else:
row_buttons.label('Fetching available projects.')
row_buttons.label(text='Fetching available projects.')
attract_box.prop(self, 'attract_project_local_path')
enabled_for = project_extensions(project)
if not project:
return
if not enabled_for:
project_box.label(text='This project is not set up for Attract or Flamenco')
return
project_box.label(text='This project is set up for: %s' %
', '.join(sorted(enabled_for)))
# This is only needed when the project is set up for either Attract or Flamenco.
project_box.prop(self, 'cloud_project_local_path',
text='Local Project Path')
def draw_flamenco_buttons(self, flamenco_box, bcp: flamenco.FlamencoManagerGroup, context):
header_row = flamenco_box.row(align=True)
header_row.label(text='Flamenco:', icon_value=icon('CLOUD'))
manager_split = flamenco_box.split(**factor(0.32), align=True)
manager_split.label(text='Manager:')
manager_box = manager_split.row(align=True)
if bcp.status in {'NONE', 'IDLE'}:
if not bcp.available_managers or not bcp.manager:
manager_box.operator('flamenco.managers',
text='Find Flamenco Managers',
icon='FILE_REFRESH')
else:
manager_box.prop(bcp, 'manager', text='')
manager_box.operator('flamenco.managers',
text='',
icon='FILE_REFRESH')
else:
manager_box.label(text='Fetching available managers.')
path_split = flamenco_box.split(**factor(0.32), align=True)
path_split.label(text='Job File Path:')
path_box = path_split.row(align=True)
path_box.prop(self, 'flamenco_job_file_path', text='')
props = path_box.operator('flamenco.explore_file_path', text='', icon='DISK_DRIVE')
props.path = self.flamenco_job_file_path
job_output_box = flamenco_box.column(align=True)
path_split = job_output_box.split(**factor(0.32), align=True)
path_split.label(text='Job Output Path:')
path_box = path_split.row(align=True)
path_box.prop(self, 'flamenco_job_output_path', text='')
props = path_box.operator('flamenco.explore_file_path', text='', icon='DISK_DRIVE')
props.path = self.flamenco_job_output_path
job_output_box.prop(self, 'flamenco_exclude_filter')
prop_split = job_output_box.split(**factor(0.32), align=True)
prop_split.label(text='Strip Components:')
prop_split.prop(self, 'flamenco_job_output_strip_components', text='')
from .flamenco import render_output_path
path_box = job_output_box.row(align=True)
output_path = render_output_path(context)
if output_path:
path_box.label(text=str(output_path))
props = path_box.operator('flamenco.explore_file_path', text='', icon='DISK_DRIVE')
props.path = str(output_path.parent)
else:
path_box.label(text='Blend file is not in your project path, '
'unable to give output path example.')
flamenco_box.prop(self, 'flamenco_relative_only')
flamenco_box.prop(self, 'flamenco_open_browser_after_submit')
class PillarCredentialsUpdate(pillar.PillarOperatorMixin,
@@ -413,8 +554,38 @@ class PILLAR_OT_subscribe(Operator):
return {'FINISHED'}
class PILLAR_OT_project_open_in_browser(Operator):
bl_idname = 'pillar.project_open_in_browser'
bl_label = 'Open in Browser'
bl_description = 'Opens a webbrowser to show the project'
project_id = StringProperty(name='Project ID')
def execute(self, context):
if not self.project_id:
return {'CANCELLED'}
import webbrowser
import urllib.parse
import pillarsdk
from .pillar import sync_call
project = sync_call(pillarsdk.Project.find, self.project_id, {'projection': {'url': True}})
if log.isEnabledFor(logging.DEBUG):
import pprint
log.debug('found project: %s', pprint.pformat(project.to_dict()))
url = urllib.parse.urljoin(PILLAR_WEB_SERVER_URL, 'p/' + project.url)
webbrowser.open_new_tab(url)
self.report({'INFO'}, 'Opened a browser at %s' % url)
return {'FINISHED'}
class PILLAR_OT_projects(async_loop.AsyncModalOperatorMixin,
pillar.PillarOperatorMixin,
pillar.AuthenticatedPillarOperatorMixin,
Operator):
"""Fetches the projects available to the user"""
bl_idname = 'pillar.projects'
@@ -424,53 +595,64 @@ class PILLAR_OT_projects(async_loop.AsyncModalOperatorMixin,
_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('Checking credentials')
try:
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
self.log.info('Going to fetch projects for user %s', self.user_id)
user_id = db_user['_id']
self.log.info('Going to fetch projects for user %s', user_id)
preferences().attract_project.status = 'FETCHING'
preferences().project.status = 'FETCHING'
# Get all projects, except the home project.
projects_user = await pillar_call(
pillarsdk.Project.all,
{'where': {'user': user_id,
{'where': {'user': self.user_id,
'category': {'$ne': 'home'}},
'sort': '-_created',
'sort': '-name',
'projection': {'_id': True,
'name': True},
'name': True,
'extension_props': True},
})
projects_shared = await pillar_call(
pillarsdk.Project.all,
{'where': {'user': {'$ne': user_id},
'permissions.groups.group': {'$in': db_user.groups}},
'sort': '-_created',
{'where': {'user': {'$ne': self.user_id},
'permissions.groups.group': {'$in': self.db_user.groups}},
'sort': '-name',
'projection': {'_id': True,
'name': True},
'name': True,
'extension_props': True},
})
# We need to convert to regular dicts before storing in ID properties.
# Also don't store more properties than we need.
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']]
def reduce_properties(project_list):
for p in project_list:
p = p.to_dict()
extension_props = p.get('extension_props', {})
enabled_for = list(extension_props.keys())
preferences().attract_project.available_projects = projects
self._log.debug('Project %r is enabled for %s', p['name'], enabled_for)
yield {
'_id': p['_id'],
'name': p['name'],
'enabled_for': enabled_for,
}
projects = list(reduce_properties(projects_user['_items'])) + \
list(reduce_properties(projects_shared['_items']))
def proj_sort_key(project):
return project.get('name')
preferences().project.available_projects = sorted(projects, key=proj_sort_key)
self.quit()
def quit(self):
preferences().attract_project.status = 'IDLE'
preferences().project.status = 'IDLE'
super().quit()
@@ -485,8 +667,16 @@ class PILLAR_PT_image_custom_properties(rna_prop_ui.PropertyPanel, bpy.types.Pan
_property_type = bpy.types.Image
def ctx_preferences():
"""Returns bpy.context.preferences in a 2.79-compatible way."""
try:
return bpy.context.preferences
except AttributeError:
return bpy.context.user_preferences
def preferences() -> BlenderCloudPreferences:
return bpy.context.user_preferences.addons[ADDON_NAME].preferences
return ctx_preferences().addons[ADDON_NAME].preferences
def load_custom_icons():
@@ -529,6 +719,7 @@ def register():
bpy.utils.register_class(SyncStatusProperties)
bpy.utils.register_class(PILLAR_OT_subscribe)
bpy.utils.register_class(PILLAR_OT_projects)
bpy.utils.register_class(PILLAR_OT_project_open_in_browser)
bpy.utils.register_class(PILLAR_PT_image_custom_properties)
addon_prefs = preferences()
@@ -563,6 +754,7 @@ def unregister():
bpy.utils.unregister_class(SyncStatusProperties)
bpy.utils.unregister_class(PILLAR_OT_subscribe)
bpy.utils.unregister_class(PILLAR_OT_projects)
bpy.utils.unregister_class(PILLAR_OT_project_open_in_browser)
bpy.utils.unregister_class(PILLAR_PT_image_custom_properties)
del WindowManager.last_blender_cloud_location

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,54 @@
import functools
import pathlib
from pillarsdk.resource import List, Find, Create
class Manager(List, Find):
"""Manager class wrapping the REST nodes endpoint"""
path = 'flamenco/managers'
PurePlatformPath = pathlib.PurePath
@functools.lru_cache()
def _sorted_path_replacements(self) -> list:
import platform
if self.path_replacement is None:
return []
items = self.path_replacement.to_dict().items()
def by_length(item):
return -len(item[0]), item[0]
this_platform = platform.system().lower()
return [(varname, platform_replacements[this_platform])
for varname, platform_replacements in sorted(items, key=by_length)
if this_platform in platform_replacements]
def replace_path(self, some_path: pathlib.PurePath) -> str:
"""Performs path variable replacement.
Tries to find platform-specific path prefixes, and replaces them with
variables.
"""
for varname, path in self._sorted_path_replacements():
replacement = self.PurePlatformPath(path)
try:
relpath = some_path.relative_to(replacement)
except ValueError:
# Not relative to each other, so no replacement possible
continue
replacement_root = self.PurePlatformPath('{%s}' % varname)
return (replacement_root / relpath).as_posix()
return some_path.as_posix()
class Job(List, Find, Create):
"""Job class wrapping the REST nodes endpoint
"""
path = 'flamenco/jobs'
ensure_query_projections = {'project': 1}

View File

@@ -112,7 +112,11 @@ class PILLAR_OT_image_share(pillar.PillarOperatorMixin,
async def async_execute(self, context):
"""Entry point of the asynchronous operator."""
self.report({'INFO'}, 'Communicating with Blender Cloud')
# We don't want to influence what is included in the screen shot.
if self.target == 'SCREENSHOT':
print('Blender Cloud add-on is communicating with Blender Cloud')
else:
self.report({'INFO'}, 'Communicating with Blender Cloud')
try:
# Refresh credentials
@@ -120,9 +124,8 @@ class PILLAR_OT_image_share(pillar.PillarOperatorMixin,
db_user = await self.check_credentials(context, REQUIRES_ROLES_FOR_IMAGE_SHARING)
self.user_id = db_user['_id']
self.log.debug('Found user ID: %s', self.user_id)
except pillar.NotSubscribedToCloudError:
self.log.exception('User not subscribed to cloud.')
self.report({'ERROR'}, 'Please subscribe to the Blender Cloud.')
except pillar.NotSubscribedToCloudError as ex:
self._log_subscription_needed(can_renew=ex.can_renew)
self._state = 'QUIT'
return
except pillar.UserNotLoggedInError:
@@ -320,15 +323,25 @@ def window_menu(self, context):
props.screenshot_full = True
def get_topbar_menu():
"""Return the topbar menu in a Blender 2.79 and 2.80 compatible way."""
try:
menu = bpy.types.TOPBAR_MT_window
except AttributeError:
# Blender < 2.80
menu = bpy.types.INFO_MT_window
return menu
def register():
bpy.utils.register_class(PILLAR_OT_image_share)
bpy.types.IMAGE_MT_image.append(image_editor_menu)
bpy.types.INFO_MT_window.append(window_menu)
get_topbar_menu().append(window_menu)
def unregister():
bpy.utils.unregister_class(PILLAR_OT_image_share)
bpy.types.IMAGE_MT_image.remove(image_editor_menu)
bpy.types.INFO_MT_window.remove(window_menu)
get_topbar_menu().remove(window_menu)

125
blender_cloud/pillar.py Normal file → Executable file
View File

@@ -62,7 +62,16 @@ class CredentialsNotSyncedError(UserNotLoggedInError):
class NotSubscribedToCloudError(UserNotLoggedInError):
"""Raised when the user may be logged in on Blender ID, but has no Blender Cloud token."""
"""Raised when the user does not have an active Cloud subscription.
:ivar can_renew: True when the user has an inactive subscription that can be renewed,
or False when the user has no subscription at all.
"""
def __init__(self, can_renew: bool):
super().__init__()
self.can_renew = can_renew
log.warning('Not subscribed to cloud, can_renew=%s', can_renew)
class PillarError(RuntimeError):
@@ -87,18 +96,18 @@ class CloudPath(pathlib.PurePosixPath):
def project_uuid(self) -> str:
assert self.parts[0] == '/'
if len(self.parts) <= 1:
return None
return ''
return self.parts[1]
@property
def node_uuids(self) -> list:
def node_uuids(self) -> tuple:
assert self.parts[0] == '/'
return self.parts[2:]
@property
def node_uuid(self) -> str:
if len(self.parts) <= 2:
return None
return ''
return self.parts[-1]
@@ -115,6 +124,12 @@ def with_existing_dir(filename: str, open_mode: str, encoding=None):
yield file_object
def _shorten(somestr: str, maxlen=40) -> str:
"""Shortens strings for logging"""
return (somestr[:maxlen - 3] + '...') if len(somestr) > maxlen else somestr
def save_as_json(pillar_resource, json_filename):
with with_existing_dir(json_filename, 'w') as outfile:
log.debug('Saving metadata to %r' % json_filename)
@@ -200,8 +215,11 @@ def pillar_api(pillar_endpoint: str = None, caching=True) -> pillarsdk.Api:
return _pillar_api[caching]
# No more than this many Pillar calls should be made simultaneously
pillar_semaphore = asyncio.Semaphore(3)
# This is an asyncio.Semaphore object, which is late-instantiated to be sure
# the asyncio loop has been created properly. On Windows we create a new one,
# which can cause this semaphore to still be linked against the old default
# loop.
pillar_semaphore = None
async def pillar_call(pillar_func, *args, caching=True, **kwargs):
@@ -214,8 +232,21 @@ async def pillar_call(pillar_func, *args, caching=True, **kwargs):
partial = functools.partial(pillar_func, *args, api=pillar_api(caching=caching), **kwargs)
loop = asyncio.get_event_loop()
async with pillar_semaphore:
# Use explicit calls to acquire() and release() so that we have more control over
# how long we wait and how we handle timeouts.
try:
await asyncio.wait_for(pillar_semaphore.acquire(), timeout=10, loop=loop)
except asyncio.TimeoutError:
log.info('Waiting for semaphore to call %s', pillar_func.__name__)
try:
await asyncio.wait_for(pillar_semaphore.acquire(), timeout=50, loop=loop)
except asyncio.TimeoutError:
raise RuntimeError('Timeout waiting for Pillar Semaphore!')
try:
return await loop.run_in_executor(None, partial)
finally:
pillar_semaphore.release()
def sync_call(pillar_func, *args, caching=True, **kwargs):
@@ -251,14 +282,15 @@ async def check_pillar_credentials(required_roles: set):
except (pillarsdk.UnauthorizedAccess, pillarsdk.ResourceNotFound, pillarsdk.ForbiddenAccess):
raise CredentialsNotSyncedError()
roles = db_user.roles or set()
log.debug('User has roles %r', roles)
if required_roles and not required_roles.intersection(set(roles)):
roles = set(db_user.roles or set())
log.getChild('check_pillar_credentials').debug('user has roles %r', roles)
if required_roles and not required_roles.intersection(roles):
# Delete the subclient info. This forces a re-check later, which can
# then pick up on the user's new status.
del profile.subclients[SUBCLIENT_ID]
profile.save_json()
raise NotSubscribedToCloudError()
raise NotSubscribedToCloudError(can_renew='has_subscription' in roles)
return db_user
@@ -441,9 +473,9 @@ async def download_to_file(url, filename, *,
log.debug('Downloading was cancelled before doing the GET')
raise asyncio.CancelledError('Downloading was cancelled')
log.debug('Performing GET %s', url)
log.debug('Performing GET %s', _shorten(url))
response = await loop.run_in_executor(None, perform_get_request)
log.debug('Status %i from GET %s', response.status_code, url)
log.debug('Status %i from GET %s', response.status_code, _shorten(url))
response.raise_for_status()
if response.status_code == 304:
@@ -457,9 +489,9 @@ async def download_to_file(url, filename, *,
log.debug('Downloading was cancelled before downloading the GET response')
raise asyncio.CancelledError('Downloading was cancelled')
log.debug('Downloading response of GET %s', url)
log.debug('Downloading response of GET %s', _shorten(url))
await loop.run_in_executor(None, download_loop)
log.debug('Done downloading response of GET %s', url)
log.debug('Done downloading response of GET %s', _shorten(url))
# We're done downloading, now we have something cached we can use.
log.debug('Saving header cache to %s', header_store)
@@ -534,7 +566,8 @@ async def fetch_texture_thumbs(parent_node_uuid: str, desired_size: str,
for texture_node in texture_nodes)
# raises any exception from failed handle_texture_node() calls.
await asyncio.gather(*coros)
loop = asyncio.get_event_loop()
await asyncio.gather(*coros, loop=loop)
log.info('fetch_texture_thumbs: Done downloading texture thumbnails')
@@ -599,7 +632,11 @@ async def download_texture_thumbnail(texture_node, desired_size: str,
# Cached headers are stored next to thumbnails in sidecar files.
header_store = '%s.headers' % thumb_path
await download_to_file(thumb_url, thumb_path, header_store=header_store, future=future)
try:
await download_to_file(thumb_url, thumb_path, header_store=header_store, future=future)
except requests.exceptions.HTTPError as ex:
log.error('Unable to download %s: %s', thumb_url, ex)
thumb_path = 'ERROR'
loop.call_soon_threadsafe(thumbnail_loaded, texture_node, file_desc, thumb_path)
@@ -747,7 +784,8 @@ async def download_texture(texture_node,
future=future)
downloaders.append(dlr)
return await asyncio.gather(*downloaders, return_exceptions=True)
loop = asyncio.get_event_loop()
return await asyncio.gather(*downloaders, return_exceptions=True, loop=loop)
async def upload_file(project_id: str, file_path: pathlib.Path, *,
@@ -773,9 +811,9 @@ async def upload_file(project_id: str, file_path: pathlib.Path, *,
log.debug('Uploading was cancelled before doing the POST')
raise asyncio.CancelledError('Uploading was cancelled')
log.debug('Performing POST %s', url)
log.debug('Performing POST %s', _shorten(url))
response = await loop.run_in_executor(None, upload)
log.debug('Status %i from POST %s', response.status_code, url)
log.debug('Status %i from POST %s', response.status_code, _shorten(url))
response.raise_for_status()
resp = response.json()
@@ -810,7 +848,6 @@ class PillarOperatorMixin:
try:
db_user = await check_pillar_credentials(required_roles)
except NotSubscribedToCloudError:
self._log_subscription_needed()
raise
except CredentialsNotSyncedError:
self.log.info('Credentials not synced, re-syncing automatically.')
@@ -821,7 +858,6 @@ class PillarOperatorMixin:
try:
db_user = await refresh_pillar_credentials(required_roles)
except NotSubscribedToCloudError:
self._log_subscription_needed()
raise
except CredentialsNotSyncedError:
self.log.info('Credentials not synced after refreshing, handling as not logged in.')
@@ -833,11 +869,46 @@ class PillarOperatorMixin:
self.log.info('Credentials refreshed and ok.')
return db_user
def _log_subscription_needed(self):
self.log.warning(
'Please subscribe to the blender cloud at https://cloud.blender.org/join')
self.report({'INFO'},
'Please subscribe to the blender cloud at https://cloud.blender.org/join')
def _log_subscription_needed(self, *, can_renew: bool, level='ERROR'):
if can_renew:
msg = 'Please renew your Blender Cloud subscription at https://cloud.blender.org/renew'
else:
msg = 'Please subscribe to the blender cloud at https://cloud.blender.org/join'
self.log.warning(msg)
self.report({level}, msg)
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
except requests.exceptions.ConnectionError:
self.log.exception('Error checking pillar credentials.')
self.report({'ERROR'}, 'Unable to connect to Blender Cloud, '
'check your internet connection.')
self.quit()
return False
self.user_id = self.db_user['_id']
return True
async def find_or_create_node(where: dict,

View File

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

View File

@@ -25,6 +25,7 @@ import functools
import logging
import pathlib
import tempfile
import typing
import shutil
import bpy
@@ -34,7 +35,7 @@ import asyncio
import pillarsdk
from pillarsdk import exceptions as sdk_exceptions
from .pillar import pillar_call
from . import async_loop, pillar, cache, blendfile, home_project
from . import async_loop, blender, pillar, cache, blendfile, home_project
SETTINGS_FILES_TO_UPLOAD = ['userpref.blend', 'startup.blend']
@@ -100,7 +101,7 @@ async def find_sync_group_id(home_project_id: str,
user_id: str,
blender_version: str,
*,
may_create=True) -> str:
may_create=True) -> typing.Tuple[str, str]:
"""Finds the group node in which to store sync assets.
If the group node doesn't exist and may_create=True, it creates it.
@@ -122,7 +123,7 @@ async def find_sync_group_id(home_project_id: str,
if not may_create and sync_group is None:
log.info("Sync folder doesn't exist, and not creating it either.")
return None, None
return '', ''
# Find/create the sub-group for the requested Blender version
try:
@@ -144,7 +145,7 @@ async def find_sync_group_id(home_project_id: str,
if not may_create and sub_sync_group is None:
log.info("Sync folder for Blender version %s doesn't exist, "
"and not creating it either.", blender_version)
return sync_group['_id'], None
return sync_group['_id'], ''
return sync_group['_id'], sub_sync_group['_id']
@@ -203,9 +204,9 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
bl_description = 'Synchronises Blender settings with Blender Cloud'
log = logging.getLogger('bpy.ops.%s' % bl_idname)
home_project_id = None
sync_group_id = None # top-level sync group node ID
sync_group_versioned_id = None # sync group node ID for the given Blender version.
home_project_id = ''
sync_group_id = '' # top-level sync group node ID
sync_group_versioned_id = '' # sync group node ID for the given Blender version.
action = bpy.props.EnumProperty(
items=[
@@ -284,9 +285,8 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
db_user = await self.check_credentials(context, REQUIRES_ROLES_FOR_SYNC)
self.user_id = db_user['_id']
log.debug('Found user ID: %s', self.user_id)
except pillar.NotSubscribedToCloudError:
self.log.exception('User not subscribed to cloud.')
self.bss_report({'SUBSCRIBE'}, 'Please subscribe to the Blender Cloud.')
except pillar.NotSubscribedToCloudError as ex:
self._log_subscription_needed(can_renew=ex.can_renew)
self._state = 'QUIT'
return
except pillar.UserNotLoggedInError:
@@ -388,12 +388,12 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
"""Loads files from the Pillar server."""
# If the sync group node doesn't exist, offer a list of groups that do.
if self.sync_group_id is None:
if not self.sync_group_id:
self.bss_report({'ERROR'},
'There are no synced Blender settings in your Blender Cloud.')
return
if self.sync_group_versioned_id is None:
if not self.sync_group_versioned_id:
self.bss_report({'ERROR'}, 'Therre are no synced Blender settings for version %s' %
self.blender_version)
return
@@ -477,13 +477,13 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
self.log.info('Overriding machine-local settings in %s', file_path)
# Remember some settings that should not be overwritten from the Cloud.
up = bpy.context.user_preferences
prefs = blender.ctx_preferences()
remembered = {}
for rna_key, python_key in LOCAL_SETTINGS_RNA:
assert '.' in python_key, 'Sorry, this code assumes there is a dot in the Python key'
try:
value = up.path_resolve(python_key)
value = prefs.path_resolve(python_key)
except ValueError:
# Setting doesn't exist. This can happen, for example Cycles
# settings on a build that doesn't have Cycles enabled.
@@ -492,7 +492,7 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
# Map enums from strings (in Python) to ints (in DNA).
dot_index = python_key.rindex('.')
parent_key, prop_key = python_key[:dot_index], python_key[dot_index + 1:]
parent = up.path_resolve(parent_key)
parent = prefs.path_resolve(parent_key)
prop = parent.bl_rna.properties[prop_key]
if prop.type == 'ENUM':
log.debug('Rewriting %s from %r to %r',

View File

@@ -18,212 +18,35 @@
import asyncio
import logging
import threading
import os
import threading
import typing
import bpy
import bgl
import blf
import pillarsdk
from . import async_loop, pillar, cache, blender, utils
from .. import async_loop, pillar, cache, blender, utils
from . import menu_item as menu_item_mod # so that we can have menu items called 'menu_item'
from . import nodes
if bpy.app.version < (2, 80):
from . import draw_27 as draw
else:
from . import draw
REQUIRED_ROLES_FOR_TEXTURE_BROWSER = {'subscriber', 'demo'}
MOUSE_SCROLL_PIXELS_PER_TICK = 50
ICON_WIDTH = 128
ICON_HEIGHT = 128
TARGET_ITEM_WIDTH = 400
TARGET_ITEM_HEIGHT = 128
ITEM_MARGIN_X = 5
ITEM_MARGIN_Y = 5
ITEM_PADDING_X = 5
library_path = '/tmp'
library_icons_path = os.path.join(os.path.dirname(__file__), "icons")
log = logging.getLogger(__name__)
class SpecialFolderNode(pillarsdk.Node):
NODE_TYPE = 'SPECIAL'
class UpNode(SpecialFolderNode):
NODE_TYPE = 'UP'
def __init__(self):
super().__init__()
self['_id'] = 'UP'
self['node_type'] = self.NODE_TYPE
class ProjectNode(SpecialFolderNode):
NODE_TYPE = 'PROJECT'
def __init__(self, project):
super().__init__()
assert isinstance(project, pillarsdk.Project), 'wrong type for project: %r' % type(project)
self.merge(project.to_dict())
self['node_type'] = self.NODE_TYPE
class MenuItem:
"""GUI menu item for the 3D View GUI."""
icon_margin_x = 4
icon_margin_y = 4
text_margin_x = 6
text_height = 16
text_width = 72
DEFAULT_ICONS = {
'FOLDER': os.path.join(library_icons_path, 'folder.png'),
'SPINNER': os.path.join(library_icons_path, 'spinner.png'),
}
FOLDER_NODE_TYPES = {'group_texture', 'group_hdri', UpNode.NODE_TYPE, ProjectNode.NODE_TYPE}
SUPPORTED_NODE_TYPES = {'texture', 'hdri'}.union(FOLDER_NODE_TYPES)
def __init__(self, node, file_desc, thumb_path: str, label_text):
self.log = logging.getLogger('%s.MenuItem' % __name__)
if node['node_type'] not in self.SUPPORTED_NODE_TYPES:
self.log.info('Invalid node type in node: %s', node)
raise TypeError('Node of type %r not supported; supported are %r.' % (
node['node_type'], self.SUPPORTED_NODE_TYPES))
assert isinstance(node, pillarsdk.Node), 'wrong type for node: %r' % type(node)
assert isinstance(node['_id'], str), 'wrong type for node["_id"]: %r' % type(node['_id'])
self.node = node # pillarsdk.Node, contains 'node_type' key to indicate type
self.file_desc = file_desc # pillarsdk.File object, or None if a 'folder' node.
self.label_text = label_text
self._thumb_path = ''
self.icon = None
self._is_folder = node['node_type'] in self.FOLDER_NODE_TYPES
self._is_spinning = False
# Determine sorting order.
# by default, sort all the way at the end and folders first.
self._order = 0 if self._is_folder else 10000
if node and node.properties and node.properties.order is not None:
self._order = node.properties.order
self.thumb_path = thumb_path
# Updated when drawing the image
self.x = 0
self.y = 0
self.width = 0
self.height = 0
def sort_key(self):
"""Key for sorting lists of MenuItems."""
return self._order, self.label_text
@property
def thumb_path(self) -> str:
return self._thumb_path
@thumb_path.setter
def thumb_path(self, new_thumb_path: str):
self._is_spinning = new_thumb_path == 'SPINNER'
self._thumb_path = self.DEFAULT_ICONS.get(new_thumb_path, new_thumb_path)
if self._thumb_path:
self.icon = bpy.data.images.load(filepath=self._thumb_path)
else:
self.icon = None
@property
def node_uuid(self) -> str:
return self.node['_id']
def represents(self, node) -> bool:
"""Returns True iff this MenuItem represents the given node."""
node_uuid = node['_id']
return self.node_uuid == node_uuid
def update(self, node, file_desc, thumb_path: str, label_text=None):
# We can get updated information about our Node, but a MenuItem should
# always represent one node, and it shouldn't be shared between nodes.
if self.node_uuid != node['_id']:
raise ValueError("Don't change the node ID this MenuItem reflects, "
"just create a new one.")
self.node = node
self.file_desc = file_desc # pillarsdk.File object, or None if a 'folder' node.
self.thumb_path = thumb_path
if label_text is not None:
self.label_text = label_text
@property
def is_folder(self) -> bool:
return self._is_folder
@property
def is_spinning(self) -> bool:
return self._is_spinning
def update_placement(self, x, y, width, height):
"""Use OpenGL to draw this one menu item."""
self.x = x
self.y = y
self.width = width
self.height = height
def draw(self, highlighted: bool):
bgl.glEnable(bgl.GL_BLEND)
if highlighted:
bgl.glColor4f(0.555, 0.555, 0.555, 0.8)
else:
bgl.glColor4f(0.447, 0.447, 0.447, 0.8)
bgl.glRectf(self.x, self.y, self.x + self.width, self.y + self.height)
texture = self.icon
err = texture.gl_load(filter=bgl.GL_NEAREST, mag=bgl.GL_NEAREST)
assert not err, 'OpenGL error: %i' % err
bgl.glColor4f(0.0, 0.0, 1.0, 0.5)
# bgl.glLineWidth(1.5)
# ------ TEXTURE ---------#
bgl.glBindTexture(bgl.GL_TEXTURE_2D, texture.bindcode[0])
bgl.glEnable(bgl.GL_TEXTURE_2D)
bgl.glBlendFunc(bgl.GL_SRC_ALPHA, bgl.GL_ONE_MINUS_SRC_ALPHA)
bgl.glColor4f(1, 1, 1, 1)
bgl.glBegin(bgl.GL_QUADS)
bgl.glTexCoord2d(0, 0)
bgl.glVertex2d(self.x + self.icon_margin_x, self.y)
bgl.glTexCoord2d(0, 1)
bgl.glVertex2d(self.x + self.icon_margin_x, self.y + ICON_HEIGHT)
bgl.glTexCoord2d(1, 1)
bgl.glVertex2d(self.x + self.icon_margin_x + ICON_WIDTH, self.y + ICON_HEIGHT)
bgl.glTexCoord2d(1, 0)
bgl.glVertex2d(self.x + self.icon_margin_x + ICON_WIDTH, self.y)
bgl.glEnd()
bgl.glDisable(bgl.GL_TEXTURE_2D)
bgl.glDisable(bgl.GL_BLEND)
texture.gl_free()
# draw some text
font_id = 0
blf.position(font_id,
self.x + self.icon_margin_x + ICON_WIDTH + self.text_margin_x,
self.y + ICON_HEIGHT * 0.5 - 0.25 * self.text_height, 0)
blf.size(font_id, self.text_height, self.text_width)
blf.draw(font_id, self.label_text)
def hits(self, mouse_x: int, mouse_y: int) -> bool:
return self.x < mouse_x < self.x + self.width and self.y < mouse_y < self.y + self.height
class BlenderCloudBrowser(pillar.PillarOperatorMixin,
async_loop.AsyncModalOperatorMixin,
bpy.types.Operator):
@@ -236,17 +59,17 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
project_name = ''
# This contains a stack of Node objects that lead up to the currently browsed node.
path_stack = []
path_stack = [] # type: typing.List[pillarsdk.Node]
# This contains a stack of MenuItem objects that lead up to the currently browsed node.
menu_item_stack = []
menu_item_stack = [] # type: typing.List[menu_item_mod.MenuItem]
timer = None
log = logging.getLogger('%s.BlenderCloudBrowser' % __name__)
_menu_item_lock = threading.Lock()
current_display_content = [] # list of MenuItems currently displayed
loaded_images = set()
current_display_content = [] # type: typing.List[menu_item_mod.MenuItem]
loaded_images = set() # type: typing.Set[str]
thumbnails_cache = ''
maximized_area = False
@@ -311,8 +134,8 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self.mouse_y = event.mouse_y
left_mouse_release = event.type == 'LEFTMOUSE' and event.value == 'RELEASE'
if self._state == 'PLEASE_SUBSCRIBE' and left_mouse_release:
self.open_browser_subscribe()
if left_mouse_release and self._state in {'PLEASE_SUBSCRIBE', 'PLEASE_RENEW'}:
self.open_browser_subscribe(renew=self._state == 'PLEASE_RENEW')
self._finish(context)
return {'FINISHED'}
@@ -365,9 +188,9 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
try:
db_user = await self.check_credentials(context, REQUIRED_ROLES_FOR_TEXTURE_BROWSER)
except pillar.NotSubscribedToCloudError:
self.log.info('User not subscribed to Blender Cloud.')
self._show_subscribe_screen()
except pillar.NotSubscribedToCloudError as ex:
self._log_subscription_needed(can_renew=ex.can_renew, level='INFO')
self._show_subscribe_screen(can_renew=ex.can_renew)
return None
if db_user is None:
@@ -375,13 +198,17 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
await self.async_download_previews()
def _show_subscribe_screen(self):
def _show_subscribe_screen(self, *, can_renew: bool):
"""Shows the "You need to subscribe" screen."""
self._state = 'PLEASE_SUBSCRIBE'
if can_renew:
self._state = 'PLEASE_RENEW'
else:
self._state = 'PLEASE_SUBSCRIBE'
bpy.context.window.cursor_set('HAND')
def descend_node(self, menu_item: MenuItem):
def descend_node(self, menu_item: menu_item_mod.MenuItem):
"""Descends the node hierarchy by visiting this menu item's node.
Also keeps track of the current node, so that we know where the "up" button should go.
@@ -390,7 +217,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
node = menu_item.node
assert isinstance(node, pillarsdk.Node), 'Wrong type %s' % node
if isinstance(node, UpNode):
if isinstance(node, nodes.UpNode):
# Going up.
self.log.debug('Going up to %r', self.current_path)
self.current_path = self.current_path.parent
@@ -402,7 +229,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self.project_name = ''
else:
# Going down, keep track of where we were
if isinstance(node, ProjectNode):
if isinstance(node, nodes.ProjectNode):
self.project_name = node['name']
self.current_path /= node['_id']
@@ -445,19 +272,20 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self.loaded_images.clear()
self.current_display_content.clear()
def add_menu_item(self, *args) -> MenuItem:
menu_item = MenuItem(*args)
def add_menu_item(self, *args) -> menu_item_mod.MenuItem:
menu_item = menu_item_mod.MenuItem(*args)
# Just make this thread-safe to be on the safe side.
with self._menu_item_lock:
self.current_display_content.append(menu_item)
self.loaded_images.add(menu_item.icon.filepath_raw)
if menu_item.icon is not None:
self.loaded_images.add(menu_item.icon.filepath_raw)
self.sort_menu()
return menu_item
def update_menu_item(self, node, *args) -> MenuItem:
def update_menu_item(self, node, *args):
node_uuid = node['_id']
# Just make this thread-safe to be on the safe side.
@@ -479,7 +307,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
return
with self._menu_item_lock:
self.current_display_content.sort(key=MenuItem.sort_key)
self.current_display_content.sort(key=menu_item_mod.MenuItem.sort_key)
async def async_download_previews(self):
self._state = 'BROWSING'
@@ -509,17 +337,17 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self.log.debug('No node UUID and no project UUID, listing available projects')
children = await pillar.get_texture_projects()
for proj_dict in children:
self.add_menu_item(ProjectNode(proj_dict), None, 'FOLDER', proj_dict['name'])
self.add_menu_item(nodes.ProjectNode(proj_dict), None, 'FOLDER', proj_dict['name'])
return
# Make sure we can go up again.
self.add_menu_item(UpNode(), None, 'FOLDER', '.. up ..')
self.add_menu_item(nodes.UpNode(), None, 'FOLDER', '.. up ..')
# Download all child nodes
self.log.debug('Iterating over child nodes of %r', self.current_path)
for child in children:
# print(' - %(_id)s = %(name)s' % child)
if child['node_type'] not in MenuItem.SUPPORTED_NODE_TYPES:
if child['node_type'] not in menu_item_mod.MenuItem.SUPPORTED_NODE_TYPES:
self.log.debug('Skipping node of type %r', child['node_type'])
continue
self.add_menu_item(child, None, 'FOLDER', child['name'])
@@ -538,6 +366,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self.add_menu_item(node, None, 'SPINNER', texture_node['name'])
def thumbnail_loaded(node, file_desc, thumb_path):
self.log.debug('Node %s thumbnail loaded', node['_id'])
self.update_menu_item(node, file_desc, thumb_path)
await pillar.fetch_texture_thumbs(node_uuid, 's', directory,
@@ -547,6 +376,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
def browse_assets(self):
self.log.debug('Browsing assets at %r', self.current_path)
bpy.context.window_manager.last_blender_cloud_location = str(self.current_path)
self._new_async_task(self.async_download_previews())
def draw_menu(self, context):
@@ -559,6 +389,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
'DOWNLOADING_TEXTURE': self._draw_downloading,
'EXCEPTION': self._draw_exception,
'PLEASE_SUBSCRIBE': self._draw_subscribe,
'PLEASE_RENEW': self._draw_renew,
}
if self._state in drawers:
@@ -566,12 +397,9 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
drawer(context)
# For debugging: draw the state
font_id = 0
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
blf.size(font_id, 20, 72)
blf.position(font_id, 5, 5, 0)
blf.draw(font_id, '%s %s' % (self._state, self.project_name))
bgl.glDisable(bgl.GL_BLEND)
draw.text((5, 5),
'%s %s' % (self._state, self.project_name),
rgba=(1.0, 1.0, 1.0, 1.0), fsize=12)
@staticmethod
def _window_region(context):
@@ -582,6 +410,12 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
def _draw_browser(self, context):
"""OpenGL drawing code for the BROWSING state."""
from . import draw
if not self.current_display_content:
self._draw_text_on_colour(context, "Communicating with Blender Cloud",
(0.0, 0.0, 0.0, 0.6))
return
window_region = self._window_region(context)
content_width = window_region.width - ITEM_MARGIN_X * 2
@@ -599,46 +433,33 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
block_height = item_height + ITEM_MARGIN_Y
bgl.glEnable(bgl.GL_BLEND)
bgl.glColor4f(0.0, 0.0, 0.0, 0.6)
bgl.glRectf(0, 0, window_region.width, window_region.height)
draw.aabox((0, 0), (window_region.width, window_region.height),
(0.0, 0.0, 0.0, 0.6))
if self.current_display_content:
bottom_y = float('inf')
bottom_y = float('inf')
# The -1 / +2 are for extra rows that are drawn only half at the top/bottom.
first_item_idx = max(0, int(-self.scroll_offset // block_height - 1) * col_count)
items_per_page = int(content_height // item_height + 2) * col_count
last_item_idx = first_item_idx + items_per_page
# The -1 / +2 are for extra rows that are drawn only half at the top/bottom.
first_item_idx = max(0, int(-self.scroll_offset // block_height - 1) * col_count)
items_per_page = int(content_height // item_height + 2) * col_count
last_item_idx = first_item_idx + items_per_page
for item_idx, item in enumerate(self.current_display_content):
x = content_x + (item_idx % col_count) * block_width
y = content_y - (item_idx // col_count) * block_height - self.scroll_offset
for item_idx, item in enumerate(self.current_display_content):
x = content_x + (item_idx % col_count) * block_width
y = content_y - (item_idx // col_count) * block_height - self.scroll_offset
item.update_placement(x, y, item_width, item_height)
item.update_placement(x, y, item_width, item_height)
if first_item_idx <= item_idx < last_item_idx:
# Only draw if the item is actually on screen.
item.draw(highlighted=item.hits(self.mouse_x, self.mouse_y))
if first_item_idx <= item_idx < last_item_idx:
# Only draw if the item is actually on screen.
item.draw(highlighted=item.hits(self.mouse_x, self.mouse_y))
bottom_y = min(y, bottom_y)
self.scroll_offset_space_left = window_region.height - bottom_y
self.scroll_offset_max = (self.scroll_offset -
self.scroll_offset_space_left +
0.25 * block_height)
else:
font_id = 0
text = "Communicating with Blender Cloud"
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
blf.size(font_id, 20, 72)
text_width, text_height = blf.dimensions(font_id, text)
blf.position(font_id,
content_x + content_width * 0.5 - text_width * 0.5,
content_y - content_height * 0.3 + text_height * 0.5, 0)
blf.draw(font_id, text)
bottom_y = min(y, bottom_y)
self.scroll_offset_space_left = window_region.height - bottom_y
self.scroll_offset_max = (self.scroll_offset -
self.scroll_offset_space_left +
0.25 * block_height)
bgl.glDisable(bgl.GL_BLEND)
# bgl.glColor4f(0.0, 0.0, 0.0, 1.0)
def _draw_downloading(self, context):
"""OpenGL drawing code for the DOWNLOADING_TEXTURE state."""
@@ -661,21 +482,15 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
'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: str, bgcolour):
content_height, content_width = self._window_size(context)
bgl.glEnable(bgl.GL_BLEND)
bgl.glColor4f(*bgcolour)
bgl.glRectf(0, 0, content_width, content_height)
font_id = 0
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
blf.size(font_id, 20, 72)
text_width, text_height = blf.dimensions(font_id, text)
draw.aabox((0, 0), (content_width, content_height), bgcolour)
draw.text((content_width * 0.5, content_height * 0.7),
text, fsize=20, align='C')
blf.position(font_id,
content_width * 0.5 - text_width * 0.5,
content_height * 0.7 + text_height * 0.5, 0)
blf.draw(font_id, text)
bgl.glDisable(bgl.GL_BLEND)
def _window_size(self, context):
@@ -692,10 +507,8 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
content_height, content_width = self._window_size(context)
bgl.glEnable(bgl.GL_BLEND)
bgl.glColor4f(0.2, 0.0, 0.0, 0.6)
bgl.glRectf(0, 0, content_width, content_height)
draw.aabox((0, 0), (content_width, content_height), (0.2, 0.0, 0.0, 0.6))
font_id = 0
ex = self.async_task.exception()
if isinstance(ex, pillar.UserNotLoggedInError):
ex_msg = 'You are not logged in on Blender ID. Please log in at User Preferences, ' \
@@ -705,20 +518,10 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
if not ex_msg:
ex_msg = str(type(ex))
text = "An error occurred:\n%s" % ex_msg
lines = textwrap.wrap(text)
lines = textwrap.wrap(text, width=100)
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
blf.size(font_id, 20, 72)
_, text_height = blf.dimensions(font_id, 'yhBp')
draw.text((content_width * 0.1, content_height * 0.9), lines, fsize=16)
def position(line_nr):
blf.position(font_id,
content_width * 0.1,
content_height * 0.8 - line_nr * text_height, 0)
for line_idx, line in enumerate(lines):
position(line_idx)
blf.draw(font_id, line)
bgl.glDisable(bgl.GL_BLEND)
def _draw_subscribe(self, context):
@@ -726,7 +529,12 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
'Click to subscribe to the Blender Cloud',
(0.0, 0.0, 0.2, 0.6))
def get_clicked(self) -> MenuItem:
def _draw_renew(self, context):
self._draw_text_on_colour(context,
'Click to renew your Blender Cloud subscription',
(0.0, 0.0, 0.2, 0.6))
def get_clicked(self) -> typing.Optional[menu_item_mod.MenuItem]:
for item in self.current_display_content:
if item.hits(self.mouse_x, self.mouse_y):
@@ -734,7 +542,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
return None
def handle_item_selection(self, context, item: MenuItem):
def handle_item_selection(self, context, item: menu_item_mod.MenuItem):
"""Called when the user clicks on a menu item that doesn't represent a folder."""
from pillarsdk.utils import sanitize_filename
@@ -806,11 +614,11 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
future=signalling_future))
self.async_task.add_done_callback(texture_download_completed)
def open_browser_subscribe(self):
def open_browser_subscribe(self, *, renew: bool):
import webbrowser
webbrowser.open_new_tab('https://cloud.blender.org/join')
url = 'renew' if renew else 'join'
webbrowser.open_new_tab('https://cloud.blender.org/%s' % url)
self.report({'INFO'}, 'We just started a browser for you.')
def _scroll_smooth(self):
@@ -865,9 +673,8 @@ class PILLAR_OT_switch_hdri(pillar.PillarOperatorMixin,
try:
db_user = await self.check_credentials(context, REQUIRED_ROLES_FOR_TEXTURE_BROWSER)
user_id = db_user['_id']
except pillar.NotSubscribedToCloudError:
self.log.exception('User not subscribed to cloud.')
self.report({'ERROR'}, 'Please subscribe to the Blender Cloud.')
except pillar.NotSubscribedToCloudError as ex:
self._log_subscription_needed(can_renew=ex.can_renew)
self._state = 'QUIT'
return
except pillar.UserNotLoggedInError:
@@ -887,13 +694,11 @@ class PILLAR_OT_switch_hdri(pillar.PillarOperatorMixin,
self._state = 'QUIT'
async def download_and_replace(self, context):
from .pillar import sanitize_filename
self._state = 'DOWNLOADING_TEXTURE'
current_image = bpy.data.images[self.image_name]
node = current_image['bcloud_node']
filename = '%s.taken_from_file' % sanitize_filename(node['name'])
filename = '%s.taken_from_file' % pillar.sanitize_filename(node['name'])
local_path = os.path.dirname(bpy.path.abspath(current_image.filepath))
top_texture_directory = bpy.path.abspath(context.scene.local_texture_dir)
@@ -903,21 +708,27 @@ class PILLAR_OT_switch_hdri(pillar.PillarOperatorMixin,
resolution = next(file_ref['resolution'] for file_ref in node['properties']['files']
if file_ref['file'] == file_uuid)
self.log.info('Downloading file %r-%s to %s', file_uuid, resolution, local_path)
self.log.debug('Metadata will be stored at %s', meta_path)
my_log = self.log
my_log.info('Downloading file %r-%s to %s', file_uuid, resolution, local_path)
my_log.debug('Metadata will be stored at %s', meta_path)
def file_loading(file_path, file_desc, map_type):
self.log.info('Texture downloading to %s (%s)',
file_path, utils.sizeof_fmt(file_desc['length']))
my_log.info('Texture downloading to %s (%s)',
file_path, utils.sizeof_fmt(file_desc['length']))
async def file_loaded(file_path, file_desc, map_type):
if context.scene.local_texture_dir.startswith('//'):
file_path = bpy.path.relpath(file_path)
self.log.info('Texture downloaded to %s', file_path)
my_log.info('Texture downloaded to %s', file_path)
current_image['bcloud_file_uuid'] = file_uuid
current_image.filepath = file_path # This automatically reloads the image from disk.
# This forces users of the image to update.
for datablocks in bpy.data.user_map({current_image}).values():
for datablock in datablocks:
datablock.update_tag()
await pillar.download_file_by_uuid(file_uuid,
local_path,
meta_path,
@@ -965,8 +776,8 @@ def _hdri_download_panel(self, current_image):
current_image.name)
return
row = self.layout.row(align=True).split(0.3)
row.label('HDRi', icon_value=blender.icon('CLOUD'))
row = self.layout.row(align=True).split(**blender.factor(0.3))
row.label(text='HDRi', icon_value=blender.icon('CLOUD'))
row.prop(current_image, 'hdri_variation', text='')
if current_image.hdri_variation != current_variation:

View File

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

View File

@@ -0,0 +1,90 @@
"""OpenGL drawing code for the texture browser.
Requires Blender 2.79 or older.
"""
import typing
import bgl
import blf
import bpy
Float2 = typing.Tuple[float, float]
Float3 = typing.Tuple[float, float, float]
Float4 = typing.Tuple[float, float, float, float]
def text(pos2d: Float2, display_text: typing.Union[str, typing.List[str]],
rgba: Float4 = (1.0, 1.0, 1.0, 1.0),
fsize=12,
align='L'):
"""Draw text with the top-left corner at 'pos2d'."""
dpi = bpy.context.user_preferences.system.dpi
gap = 12
x_pos, y_pos = pos2d
font_id = 0
blf.size(font_id, fsize, dpi)
# Compute the height of one line.
mwidth, mheight = blf.dimensions(font_id, "Tp") # Use high and low letters.
mheight *= 1.5
# Split text into lines.
if isinstance(display_text, str):
mylines = display_text.split("\n")
else:
mylines = display_text
maxwidth = 0
maxheight = len(mylines) * mheight
for idx, line in enumerate(mylines):
text_width, text_height = blf.dimensions(font_id, line)
if align == 'C':
newx = x_pos - text_width / 2
elif align == 'R':
newx = x_pos - text_width - gap
else:
newx = x_pos
# Draw
blf.position(font_id, newx, y_pos - mheight * idx, 0)
bgl.glColor4f(*rgba)
blf.draw(font_id, " " + line)
# saves max width
if maxwidth < text_width:
maxwidth = text_width
return maxwidth, maxheight
def aabox(v1: Float2, v2: Float2, rgba: Float4):
"""Draw an axis-aligned box."""
bgl.glColor4f(*rgba)
bgl.glRectf(*v1, *v2)
def aabox_with_texture(v1: Float2, v2: Float2):
"""Draw an axis-aligned box with a texture."""
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
bgl.glEnable(bgl.GL_TEXTURE_2D)
bgl.glBegin(bgl.GL_QUADS)
bgl.glTexCoord2d(0, 0)
bgl.glVertex2d(v1[0], v1[1])
bgl.glTexCoord2d(0, 1)
bgl.glVertex2d(v1[0], v2[1])
bgl.glTexCoord2d(1, 1)
bgl.glVertex2d(v2[0], v2[1])
bgl.glTexCoord2d(1, 0)
bgl.glVertex2d(v2[0], v1[1])
bgl.glEnd()
bgl.glDisable(bgl.GL_TEXTURE_2D)
def bind_texture(texture: bpy.types.Image):
"""Bind a Blender image to a GL texture slot."""
bgl.glBindTexture(bgl.GL_TEXTURE_2D, texture.bindcode[0])

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

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

View File

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

View File

@@ -16,7 +16,9 @@
#
# ##### END GPL LICENSE BLOCK #####
import json
import pathlib
import typing
def sizeof_fmt(num: int, suffix='B') -> str:
@@ -28,12 +30,12 @@ def sizeof_fmt(num: int, suffix='B') -> str:
for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']:
if abs(num) < 1024:
return '%.1f %s%s' % (num, unit, suffix)
num /= 1024
num //= 1024
return '%.1f Yi%s' % (num, suffix)
def find_in_path(path: pathlib.Path, filename: str) -> pathlib.Path:
def find_in_path(path: pathlib.Path, filename: str) -> typing.Optional[pathlib.Path]:
"""Performs a breadth-first search for the filename.
Returns the path that contains the file, or None if not found.
@@ -62,3 +64,52 @@ def find_in_path(path: pathlib.Path, filename: str) -> pathlib.Path:
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):
if context.area is None:
return
context.area.tag_redraw()
class JSONEncoder(json.JSONEncoder):
"""JSON encoder with support for some Blender types."""
def default(self, o):
if o.__class__.__name__ == 'IDPropertyGroup' and hasattr(o, 'to_dict'):
return o.to_dict()
return super().default(o)

View File

@@ -44,6 +44,12 @@ def load_wheel(module_name, fname_prefix):
module_name, module.__file__, fname_prefix)
return
sys.path.append(wheel_filename(fname_prefix))
module = __import__(module_name)
log.debug('Loaded %s from %s', module_name, module.__file__)
def wheel_filename(fname_prefix: str) -> str:
path_pattern = os.path.join(my_dir, '%s*.whl' % fname_prefix)
wheels = glob.glob(path_pattern)
if not wheels:
@@ -51,12 +57,11 @@ def load_wheel(module_name, fname_prefix):
# If there are multiple wheels that match, load the latest one.
wheels.sort()
sys.path.append(wheels[-1])
module = __import__(module_name)
log.debug('Loaded %s from %s', module_name, module.__file__)
return wheels[-1]
def load_wheels():
load_wheel('blender_asset_tracer', 'blender_asset_tracer')
load_wheel('lockfile', 'lockfile')
load_wheel('cachecontrol', 'CacheControl')
load_wheel('pillarsdk', 'pillarsdk')

13
deploy-to-shared.sh Executable file
View File

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

View File

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

View File

@@ -18,7 +18,6 @@
# ##### END GPL LICENSE BLOCK #####
import glob
import os
import sys
import shutil
import subprocess
@@ -29,13 +28,18 @@ import zipfile
from distutils import log
from distutils.core import Command
from distutils.command.bdist import bdist
from distutils.command.install import install
from distutils.command.install import install, INSTALL_SCHEMES
from distutils.command.install_egg_info import install_egg_info
from setuptools import setup, find_packages
requirement_re = re.compile('[><=]+')
sys.dont_write_bytecode = True
# Download wheels from pypi. The specific versions are taken from requirements.txt
wheels = [
'lockfile', 'pillarsdk', 'blender-asset-tracer',
]
def set_default_path(var, default):
"""convert CLI-arguments (string) to Paths"""
@@ -60,6 +64,7 @@ class BuildWheels(Command):
self.wheels_path = None # path that will contain the installed wheels.
self.deps_path = None # path in which dependencies are built.
self.cachecontrol_path = None # subdir of deps_path containing CacheControl
self.bat_path = None # subdir of deps_path containing Blender-Asset-Tracer
def finalize_options(self):
self.my_path = pathlib.Path(__file__).resolve().parent
@@ -69,6 +74,7 @@ class BuildWheels(Command):
self.deps_path = set_default_path(self.deps_path, self.my_path / 'build/deps')
self.cachecontrol_path = set_default_path(self.cachecontrol_path,
self.deps_path / 'cachecontrol')
self.bat_path = self.deps_path / 'bat'
def run(self):
log.info('Storing wheels in %s', self.wheels_path)
@@ -90,16 +96,11 @@ class BuildWheels(Command):
# log.info(' - %s = %s / %s', package, line, line_req[-1])
self.wheels_path.mkdir(parents=True, exist_ok=True)
# Download lockfile, as there is a suitable wheel on pypi.
if not list(self.wheels_path.glob('lockfile*.whl')):
log.info('Downloading lockfile wheel')
self.download_wheel(requirements['lockfile'])
# Download Pillar Python SDK from pypi.
if not list(self.wheels_path.glob('pillarsdk*.whl')):
log.info('Downloading Pillar Python SDK wheel')
self.download_wheel(requirements['pillarsdk'])
for package in wheels:
pattern = package.replace('-', '_') + '*.whl'
if list(self.wheels_path.glob(pattern)):
continue
self.download_wheel(requirements[package])
# Build CacheControl.
if not list(self.wheels_path.glob('CacheControl*.whl')):
@@ -164,6 +165,14 @@ class BlenderAddonBdist(bdist):
super().initialize_options()
self.formats = ['zip']
self.plat_name = 'addon' # use this instead of 'linux-x86_64' or similar.
self.fix_local_prefix()
def fix_local_prefix(self):
"""Place data files in blender_cloud instead of local/blender_cloud."""
for key in INSTALL_SCHEMES:
if 'data' not in INSTALL_SCHEMES[key]:
continue
INSTALL_SCHEMES[key]['data'] = '$base'
def run(self):
self.run_command('wheels')
@@ -175,12 +184,12 @@ class BlenderAddonFdist(BlenderAddonBdist):
"""Ensures that 'python setup.py fdist' creates a plain folder structure."""
user_options = [
('dest-path=', None, 'addon installation path'),
('dest-path=', None, 'addon installation path'),
]
def initialize_options(self):
super().initialize_options()
self.dest_path = None # path that will contain the addon
self.dest_path = None # path that will contain the addon
def run(self):
super().run()
@@ -227,12 +236,16 @@ setup(
'wheels': BuildWheels},
name='blender_cloud',
description='The Blender Cloud addon allows browsing the Blender Cloud from Blender.',
version='1.5.2',
version='1.11.1',
author='Sybren A. Stüvel',
author_email='sybren@stuvel.eu',
packages=find_packages('.'),
data_files=[('blender_cloud', ['README.md']),
('blender_cloud/icons', glob.glob('blender_cloud/icons/*'))],
data_files=[
('blender_cloud', ['README.md', 'README-flamenco.md', 'CHANGELOG.md']),
('blender_cloud/icons', glob.glob('blender_cloud/icons/*')),
('blender_cloud/texture_browser/icons',
glob.glob('blender_cloud/texture_browser/icons/*'))
],
scripts=[],
url='https://developer.blender.org/diffusion/BCA/',
license='GNU General Public License v2 or later (GPLv2+)',

View File

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

View File

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