Compare commits

...
This repository has been archived on 2023-10-03. You can view files and clone it, but cannot push or open issues or pull requests.

573 Commits

Author SHA1 Message Date
d940735453 Mark version 1.25 as released today 2022-02-25 15:45:55 +01:00
7d71067b3d Use BAT version 1.11 for UDIM support 2022-02-25 15:45:55 +01:00
b0b804410d Bumped version to 1.25 2022-02-25 15:45:55 +01:00
d55f2dcee1 Compatibility with Blender 3.1 / Python 3.10
Blender 3.1 will be shipped with Python 3.10, which made some backward-
incompatible changes in its asyncio module (the removal of the `loop`
parameter from various functions).

Depending on which Python version is used in Blender, the add-on now
passes (or not) the `loop` parameter, retaining backward compatibility.
2022-02-25 15:45:52 +01:00
2fbb5ac788 Bumped version to 1.24 2022-02-04 11:01:55 +01:00
b47b407589 Update CHANGELOG.md 2022-02-04 10:58:55 +01:00
a136366804 Upgrade to BAT 1.10
Upgrade to BAT to fix doubly-compressed blend files.

This also changes the way the wheel files are loaded, as the old
alphabetical ordering won't pick up on BAT 1.10. It now uses the
modification time of the wheel files to find the latest one.
2022-02-04 10:57:45 +01:00
6718e1646f Attract: prevent rare error in ATTRACT_OT_open_meta_blendfile 2021-11-19 15:46:19 +01:00
9d7f9a979e Bumped version to 1.23 2021-11-09 11:25:58 +01:00
326a793de0 Bump BAT 1.7 → 1.8
Bump BAT version to allow sending read-only files to Flamenco.
2021-11-09 11:25:44 +01:00
88ccb0f376 Bumped version to 1.22 2021-11-05 16:33:07 +01:00
5b8895278a Mark version 1.22 as released today 2021-11-05 16:33:00 +01:00
eb37d20039 Bump blender-asset-tracer 1.6 → 1.7
BAT v1.7 adds support for zstandard-compressed files, which are written
by Blender 3.0
2021-11-05 16:32:32 +01:00
4f49e8ca0b Cleanup: remove some unused imports 2021-11-05 16:25:27 +01:00
c931700fec Cleanup: formatting with Black
No functional changes.
2021-07-29 19:34:11 +02:00
6285826bfc Fix Windows incompatibility when using Shaman URLs as job storage path
The Shaman URL check was done on the wrong string, which went unnoticed
on Linux because an URL is a valid file path. However, on Windows this is
not the case, and thus caused problems. This is now fixed.
2021-07-29 19:33:36 +02:00
25150397c0 Bumped version to 1.21 2021-07-27 17:12:18 +02:00
c67b161e3d Bump blender-asset-tracer version 1.5.1 → 1.6
BAT 1.6 has better compatibility with Geometry Nodes.
2021-07-27 17:12:18 +02:00
f76dcb964e Bumped version to 1.20 2021-07-22 17:16:00 +02:00
2d868ec724 Disable Strict Pointer Mode in Blender Asset Tracer
Disable BAT's Strict Pointer Mode to work around issues with dangling
pointers in the Blender Animation Studio files. These seem to be caused
by not-perfectly-resynced library overrides. Ignoring those pointers
seems to cause less problems than crashing on them.
2021-07-22 16:44:53 +02:00
666ae0fa90 Bump blender-asset-tracer version 1.3.1 → 1.5.1
Bump BAT version to have it tested on the currently used Python version
(3.9) and to have the ability to disable Strict Pointer Mode.
2021-07-22 16:44:47 +02:00
49844e17b2 Bumped version to 1.19 2021-02-23 11:58:09 +01:00
06432a3534 Mark 1.19 as released in CHANGELOG.md 2021-02-23 11:58:03 +01:00
3a2e9bc672 Simplify @pyside_cache decorator
This fixes a compatibility issue with Python 3.9+, and at the same time
avoids a not-yet-quite-stable area of Blender's Python API.
2021-02-23 11:57:29 +01:00
ce331c7b22 Mark 1.18 as released 2021-02-16 11:58:05 +01:00
8b5dc65d84 Bumped version to 1.18 2021-02-16 11:58:05 +01:00
3bc7dcfa9e Update update_script.sh for new formatting with Black 2021-02-16 11:58:05 +01:00
d9fe24ece7 Cleanup: reformat setup.py with Black
No functional changes.
2021-02-16 11:58:02 +01:00
dd00bc9cb5 Don't save preferences when exiting with "Send & Quit" button
The "Save & Quit" button disables the exit confirmation box, and that
change shouldn't be auto-saved.
2021-02-16 11:48:43 +01:00
14778e5c08 Remove code to support Blender 2.79 and older 2021-02-16 11:48:43 +01:00
8b49c5505e Reformat with Black
No functional changes.
2021-02-16 11:48:43 +01:00
883f125722 Compatibility with Blender 2.93 / Python 3.9 → require Blender 2.80+
The code now requires Python 3.7 or newer, as a side-effect of the changes
required for compatibility with 3.9 (as used in Blender 2.93). As a result,
Blender Cloud Add-on now requires Blender 2.80 or newer.
2021-02-16 11:48:43 +01:00
2fbe7e1258 Slightly more compressed changelog
No functional changes.
2021-02-16 10:28:47 +01:00
405b823c81 Bumped version to 1.17 2021-02-04 12:04:46 +01:00
9e952035d3 Upgrade BAT 1.2.1 → 1.3.1
Upgrade BAT to version 1.3.1, which brings compatibility with Geometry
Nodes and fixes some issues on Windows.
2021-02-04 12:04:26 +01:00
d77acfb9c8 Reduce logging noise
- No longer list Attract's RNA classes, these haven't changed in a long
  time and it's not interesting to see.
- Reduced log level when updating internal state. The result of the update
  is already logged at INFO level.
2020-11-12 12:31:17 +01:00
70de9741df Bumped version to 1.16 2020-03-03 10:39:11 +01:00
cc37e73bc6 Fix T74211: Windows compatibility with Shaman URL handling 2020-03-03 10:38:53 +01:00
e32e75e3db Bumped version to 1.15 and marked as released in CHANGELOG 2019-12-12 10:42:08 +01:00
6fa5ab5481 Removed trailing period from property description
No functional changes.
2019-12-12 10:40:58 +01:00
379580de86 Don't create BAT pack when rendering file in job storage directory
When the to-be-rendered blend file is contained in the job storage
directory, it is now assumed that all files are already reachable by the
Flamenco Workers. This supports environments working directly on shared
storage.

This assumes that the paths are already correct for the Flamenco
Workers. No detection of missing files is done (as BAT doesn't run).
2019-10-25 13:34:34 +02:00
db30b3df76 Bumped version to 1.14 2019-10-10 10:39:37 +02:00
5de99baaef Updated changelog 2019-10-10 10:39:28 +02:00
2184b39d27 Bump Blender Asset Tracer (BAT) version from 1.1.1 → 1.2.1 2019-10-10 10:29:53 +02:00
23b1f7de7d Convert property definitions from assignment to annotations on Blender 2.80+
The properties are still declared in the Python 3.5 compatible assignment
notation, and a class decorator that converts those to class annotations
as preferred by Blender 2.80.
2019-10-10 10:29:36 +02:00
28f68c6fbf update_version.sh: Use Python 3 in example command
This makes it possible to run the command outside of a Python 3 virtualenv.
2019-06-21 14:31:54 +02:00
b00cb233cc Bumped version to 1.13.5 2019-06-21 14:30:03 +02:00
2142e9e7fc Attract fix for Blender 2.80 panel change
Commit 1e7c3a159fd2ca42fd5688be067008ef0d2c03df removed the 'Info' panel
(which is good), so we have to attach the metadata subpanel somewhere else.
2019-06-21 14:29:49 +02:00
1dea802932 Attract doesn't have to be active to use ATTRACT_OT_open_meta_blendfile
It is pretty much independent of Attract.
2019-06-21 14:29:07 +02:00
077bd1abdb Prevent KeyError when Flamenco Manager settings are unknown 2019-06-12 11:47:16 +02:00
5a2c528681 Run Pip via {sys.executable} -m pip
This solves the same problem as c457767edf814f92e1da8cb9d08fa52404ea074c,
but in a way that's actually [recommended](https://pip.pypa.io/en/latest/user_guide/#using-pip-from-your-program).
2019-06-04 12:40:02 +02:00
53b12376d1 Revert "Use Python module to run Pip"
This reverts commit c457767edf814f92e1da8cb9d08fa52404ea074c. Modern pip
can no longer be used this way ('pip.main' does not exist).
2019-06-04 12:35:46 +02:00
8495868ea6 Bumped version to 1.13.4 2019-06-04 12:29:50 +02:00
cf810de41b Another Blender 2.8 compatibility fix 2019-06-04 12:29:37 +02:00
c457767edf Use Python module to run Pip
setup.py used systemcalls for package management pip. This call is
platform dependent as on ubuntu distros this needs to be pip3. On these
platforms pip points to the python2 version.

By direct calling the pip module from within the running python process
we know for sure we are triggering the correct one.

Differential revision: https://developer.blender.org/D4952/

Reviewed by: sybren
2019-05-29 10:29:14 +02:00
985b3f6a7d Attract: draw strip metadata as its own panel
The panel is a subpanel in Blender 2.80, and a top-level panel in 2.79.
2019-05-24 14:12:36 +02:00
a45bf3cd5c Bumped version to 1.13.3 2019-05-21 10:19:49 +02:00
3789742cc8 Fixed little bug
Missed a function call in a69f4d3fd91958e2fdbc94e661bae10ba1d7f139.
2019-05-21 10:19:34 +02:00
58f374e175 Bumped version to 1.13.2 2019-05-17 11:26:40 +02:00
99e90e1008 Mark version 1.13 as released 2019-05-17 11:26:29 +02:00
dd83d3ee60 Blender 2.80 compatibility for Attract panel in sequence editor 2019-05-17 11:15:34 +02:00
e74e014c66 Quick fix for Blender 2.80 texture loading
The `Image.gl_load()` call was changed in Blender commit
7ad802cf3ae500bc72863b6dba0f28a488fce3d1; the two parameters we were using
were removed.

This commit fixes the exception and makes the texture browser usable again,
but doesn't properly fix everything. The textures are drawn in the wrong
colour space, which will be fixed in another commit once I know how.
2019-05-17 11:09:57 +02:00
01541f181e Bumped Pillar Python SDK 1.7.0 → 1.8.0 2019-05-14 11:05:51 +02:00
a69f4d3fd9 Flamenco: Moved some code around, no semantic changes 2019-05-10 12:29:39 +02:00
3ffea46f23 Bumped version to 1.13.1 2019-04-18 12:58:49 +02:00
94c5811e42 Typo 2019-04-18 12:58:34 +02:00
676ad1ed14 Removed unused import 2019-04-18 12:46:42 +02:00
79e6fa37f4 Bumped version to 1.13.0 2019-04-18 12:10:30 +02:00
e06fa3ea75 Flamenco: Support for Flamenco Manager settings version 2
When using Blender Cloud Add-on 1.12 or older, Flamenco Server will
automatically convert the Manager settings to version 1. As a result,
upgrading is recommended but not required to keep working with a newer
Flamenco Server.
2019-04-18 12:09:54 +02:00
fb6352dc7d Upgraded BAT to 1.1.1 for a compatibility fix with Blender 2.79 2019-04-18 12:06:43 +02:00
97ad8bf5ba Flamenco: sort path replacement vars by replacement, not by variable name
The longer paths need to be replaced first. Not the longer variable name.
2019-04-18 11:07:36 +02:00
b0f7719add Fix pyrna_enum_to_py: current value matches no enum warnings 2019-03-26 12:36:13 +01:00
dada275e32 Bumped version to 1.12.1 2019-03-26 11:32:10 +01:00
6bce1ccf90 Bumped BAT requirement to 1.1 2019-03-25 17:48:28 +01:00
bbe524c099 Updated CHANGELOG 2019-03-25 17:44:56 +01:00
462da038ec Fixed Blender 2.79 incompatibility 2019-03-20 13:58:56 +01:00
8d7799655e Bumped BAT to 1.1.dev2 2019-03-20 13:58:47 +01:00
cb0393868e Flamenco: get JWT token from Flamenco Server when sending files to Shaman 2019-03-13 15:09:24 +01:00
5a61a7a6c4 Use exponential backoff in uncached_session 2019-03-13 15:08:56 +01:00
60d1fbff50 Blender changed use_quit_dialog into use_save_prompt 2019-03-13 10:07:23 +01:00
352fe239f2 Flamenco: Use DNA enum value for format setting
See https://developer.blender.org/D4502 and https://developer.blender.org/rF032423271d0417aed3b6053adb8b6db2774b0d36
for more info.
2019-03-12 15:27:27 +01:00
09c1bf67b4 Bumped BAT to 1.1-dev1 2019-03-06 13:41:49 +01:00
23235afe71 Updated CHANGELOG 2019-03-06 13:32:38 +01:00
ff9624d4f3 Blender Video Chunks: also allow .mp4 and .mov as container format 2019-03-06 13:31:30 +01:00
48c60f73d7 Bundle with BAT 1.1-dev0 for Shaman support
See https://gitlab.com/blender-institute/shaman for more info.
2019-03-01 14:37:44 +01:00
12eaaa5bae Set min job priority to 1
Previously the minimum was 0, but the server only accepts 1 and up.
2019-03-01 14:36:41 +01:00
f7396350db Add support for Shaman servers
See https://gitlab.com/blender-institute/shaman for more info
2019-02-28 12:53:29 +01:00
cc97288018 Create job first, then send files
This requires Flamenco Server 2.2 or newer.
2019-02-28 12:52:51 +01:00
26105add9c Updated BAT to 0.99 2019-02-26 16:48:39 +01:00
ea81cc5769 Flamenco: Name render jobs just 'thefile' instead of 'Render thefile.flamenco.blend'
This makes the job list on Flamenco Server cleaner.
2019-02-13 15:18:33 +01:00
25b6053836 Allow project selection, even when the current project is ''. 2019-02-13 14:29:36 +01:00
65a05403dc Bumped BAT to 0.9 2019-02-12 12:33:31 +01:00
770b0121fa Flamenco: Different label for 'frame chunk' depending on render job type
The frame chunk size has a slightly different meaning when rendering
progressively (Flamenco Server can choose to chunk more frames together
when rendering a low number of samples).
2019-02-06 09:32:24 +01:00
2b155eac45 Flamenco: show a warning when the frame dimensions are not divisible by 2
Any 'Create Video' Flamenco task that's part of the job will pad the video
with black pixels to make the dimensions even, and this warning notifies
the artist about this.
2019-02-04 11:39:14 +01:00
d36959e91b Flamenco: Fixed tiny layout bug 2019-02-04 11:37:04 +01:00
9028c38c68 Fixed "You are not logged in" message 2019-02-01 17:20:01 +01:00
f04f07eaf1 Bumped version to 1.12.0 2019-01-31 14:43:08 +01:00
6c38a432bc Flamenco: Added a hidden "Submit & Quit" button.
This button can be enabled in the add-on preferences and and then be
available on the Flamenco Render panel. Pressing the button will
silently close Blender after the job has been submitted to Flamenco (for
example to click, walk away, and free up memory for when the same
machine is part of the render farm).
2019-01-31 14:42:50 +01:00
53fa3e628a Flamenco: disable Cycles denoiser when progressive rendering
The denoiser data cannot be (easily) merged, so for now we just disable
the denoiser.
2019-01-30 16:10:09 +01:00
924fb45cb2 Flamenco: disallow progressive rendering unless Cycles is used 2019-01-30 16:06:39 +01:00
b5619757bc Flamenco: disallow progressive rendering on Blender < 2.80
Rendering ranges of sample chunks only works reliably for us after
Blender commit 7744203b7fde35a074faf232dda3595b78c5f14c (Tue Jan 29
18:08:12 2019 +0100).
2019-01-30 16:06:39 +01:00
ae41745743 Flamenco: easy button for setting max sample count for progressive rendering 2019-01-30 16:06:39 +01:00
ffab83f921 Flamenco: no longer use the word 'chunks' in the UI
It's a confusing word; 'Frames per Task' is clearer.
2019-01-30 16:06:39 +01:00
8bef2f48a5 Flamenco: Move job-type-specific options to a box below job type selector
This should make the relation between the job type and its options clearer.
2019-01-30 16:04:43 +01:00
74b46ff0db Flamenco: Progressive Rendering max sample count instead of chunk count
Flamenco Server changed from expecting a fixed number of sample chunks to
a compile-time determined number of nonuniform chunks. The artist can now
influence the size of each render task by setting a maximum number of
samples per render task.
2019-01-30 16:04:43 +01:00
e1934b20d9 Flamenco: nicer error reporting when creating a job fails 2019-01-30 13:05:09 +01:00
0caf761863 Prevent error when running Blender in background mode
We shouldn't call any `gpu` functions in background mode. Since the texture
browser will never run when Blender is in background mode anyway, we can
simply assign `None` instead.
2019-01-04 16:25:50 +01:00
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
68b046c714 Bumped version to 1.5.2 2017-01-06 17:01:58 +01:00
cb20d6ee03 Fixed icon, the MOVE_UP_VEC icon was removed
This happened in Blender commit rBb2159b94bcd6c4e30c47b9970601e6f2ca2a0750
2017-01-06 17:01:06 +01:00
Dalai Felinto
645bdd950f Attract: prevent ui/console errors when no strip exists 2016-12-06 22:00:58 +01:00
Dalai Felinto
74a5830dae setup fdist option for files distribution (unzipped files)
This is the equivalent of `python setup.py bdist` and unzipping the outputted file. If no --dest-path is specified it dumps the files in the same folder as the .zip.

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

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

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

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

Fixes T49203.
2016-09-06 12:30:48 +02:00
73a62da8da Fixed some issues with new db_user-returning credential check. 2016-08-30 16:57:21 +02:00
2c70ceb489 Solved issue T48992 2016-08-30 16:33:59 +02:00
38ccb54b50 Switch to new API URL. 2016-08-30 16:33:51 +02:00
1df113ca01 check_credentials() now returns the entire user, not just the ID. 2016-08-26 17:43:40 +02:00
887a9cc697 Allow async operators to automatically quit when they raise an exception.
Just set the class property `stop_upon_exception=True`.
2016-08-26 17:43:40 +02:00
143456ae1d Made AsyncModalOperatorMixin.invoke() start self.async_execute(context).
This was already common practice in all subclasses, and has now been
moved into the mixin.
2016-08-26 17:43:40 +02:00
f41ea8c5a3 Ignore __pycache__ dirs 2016-08-26 16:16:21 +02:00
7d90a92e24 Bumped version to 1.4.3 2016-08-23 14:41:33 +02:00
2388f800dc Fix T49080: Blender Cloud add-on error uploading screenshot
The screenshot filename contained colons, which isn't allowed on Windows.
2016-08-23 14:40:41 +02:00
38a3bcba71 Bumped version to 1.4.2, to re-distribute with B'ID addon 1.1.0 2016-08-04 14:39:00 +02:00
2cf400a74c Remove trailing slash from pillar_endpoint for BlenderID Addon 1.1.0
BlenderID Addon 1.1.0 uses endpoint URLs differently, so now directory-
like URLs have to end in a slash.
2016-08-04 12:46:42 +02:00
54ebb0bf5d Removed support: OFFICIAL, as that's reserved for Blender-bundled addons. 2016-08-04 11:21:59 +02:00
9e84d2a416 Only ignore blend files at the root dir 2016-07-29 11:05:51 +02:00
772e6b0b1b bundle.sh: warn when an addon can't be found. 2016-07-29 11:02:31 +02:00
b6232c8c13 Bumped version to 1.4.1 2016-07-27 18:38:10 +02:00
6d4ba51c6c Added missing callback argument 2016-07-27 18:37:29 +02:00
b9caecfce9 Removed some unused code 2016-07-26 16:02:15 +02:00
4ce8db88c6 Tagged version as 1.4.0 2016-07-26 15:30:23 +02:00
dfff0cb55b Bumped pillarsdk requirement to 1.5.0 2016-07-26 15:30:16 +02:00
1a515bfbda Texture browser: removed the blue line :( 2016-07-22 18:25:24 +02:00
a47dfa8f32 Texture browser: UI polish for HDRi variation selector 2016-07-22 18:25:20 +02:00
8890ad5421 Texture browser: storing desired HDRi variation on image, not window mgr
Also added an HDRi panel in the node properties.
2016-07-22 18:06:13 +02:00
f0d42ed2cc Texture browser: on click on HDRi, always just download the smallest image.
The larger ones can be downloaded later from the GUI.
The addon assumes that the first image in the node.properties.files list is
the smallest one. This can be ensured on a per-project basis by running
'manage.py hdri_sort {project URL}' on the Pillar server.
2016-07-22 17:49:29 +02:00
76ca59251b Texture browser: nicer handling of still-loading menu items. 2016-07-22 17:47:37 +02:00
b33ec74347 Texture browser: Use node name as file name 2016-07-22 17:47:37 +02:00
b5e33c52c1 Texture browser: simplified HDRi replacing.
It now just loads a new image into the existing image datablock.
2016-07-22 17:47:37 +02:00
8b56918989 Texture browser: HDRi variation selector now defaults to variation of the current image. 2016-07-22 17:47:31 +02:00
99257bd88b Added HDRi variation swap operator.
The variation/resolution selector isn't final yet.
2016-07-22 15:39:08 +02:00
c2363d248b Settings sync: solved etag mismatch issue.
This was caused by doing a cached request, which would always return
the etag of the previous request.
2016-07-22 12:57:15 +02:00
3776246d70 Texture browser: load images with relative path if needed.
If the local texture path of the current scene is relative, the image
will also be stored in a relative path.
2016-07-22 12:56:37 +02:00
9bc8c30443 Moved invoke-calling execute function to AsyncModalOperatorMixin
because this is what you'd generally want from an async operator
2016-07-22 12:48:50 +02:00
56b622a723 Texture browser: save Node document with downloaded image. 2016-07-21 16:25:58 +02:00
8edf9c7428 Texture browser: Don't show spinner for HDRi files 2016-07-21 14:09:52 +02:00
10bf3e62ec Marked as beta release in setup.py 2016-07-21 12:11:02 +02:00
3ec1a3d26d Made HRDi browsing more efficient.
It now uses the thumbnail of the node for each file, instead of trying
to download each file's thumbnail individually.
2016-07-21 12:10:29 +02:00
3ce89ad5f4 Show file size for HDRi files. 2016-07-21 11:17:53 +02:00
7cf858855e PEP8 formatting 2016-07-21 11:03:30 +02:00
a10b4a804c Added support for HDRi nodes.
These nodes are like textures, except that here the user should choose
which variation to download (instead of downloading them all).
2016-07-21 11:03:23 +02:00
514968de40 Texture browser: set downloaded image as active in image editor.
If the current context is the image editor, that is.
2016-07-20 17:08:57 +02:00
c73dce169f Added a panel that shows custom properties in the image editor. 2016-07-20 16:54:06 +02:00
369e082880 Added GPL License block to the top of each .py file. 2016-07-20 16:32:01 +02:00
6cd9cb1713 Texture browser: clicking on HDRi node no longer causes exception.
The browser still downloads all HDRi files, though.
2016-07-20 16:17:48 +02:00
37f701edaf Added texture browser to the image menu.
The Ctrl+Alt+Shift+A shortcut still works everywhere, but now it's also
easy to find in the GUI.
2016-07-20 16:09:56 +02:00
b04f9adb40 Texture browser: Don't use file name as menu item label.
Just using the node name is clearer, as it only depends on the node, and
no longer on the linked files themselves. This also makes it easier to
get compatible with HDRi nodes (as those files won't be named
"{name}-{maptype}".
2016-07-20 16:02:56 +02:00
70a0aba10a Allow browsing group_hdri nodes.
Nodes of type 'hdri' don't work well yet.
2016-07-20 15:58:09 +02:00
f6d05c4c84 Bumped version to 1.4.0 (otherwise we don't get HDRi projects from Cloud) 2016-07-20 14:27:00 +02:00
8e9d62b5c5 Include addon version in all Pillar HTTP requests 2016-07-20 14:26:36 +02:00
e300c32d64 Bumped version to 1.3.3 2016-07-20 11:13:31 +02:00
63eaaf7dc9 Added addon-bundle dir 2016-07-20 10:59:17 +02:00
6fcea9469f Limit scrolling to content area. 2016-07-19 18:13:32 +02:00
61f86d63e0 Scrolling on MacOS X 2016-07-19 18:13:18 +02:00
0d69b1d7ec Removed trailing period from bl_desc 2016-07-19 18:13:09 +02:00
d5139c767e Texture browser: Added scrolling.
You can scroll indefinitely for now. Might fix that in a later commit.
2016-07-15 17:01:24 +02:00
f0d829da49 Renamed some constants to all-caps 2016-07-15 16:59:52 +02:00
a4817259c8 Moved import 2016-07-15 16:56:55 +02:00
f899f6d1ab Started pagination support, but it isn't used yet. 2016-07-15 16:56:39 +02:00
9a0873eea4 Renamed gui.py to texture_browser.py
Also discovered double-unregister of a class, so that fixed an old bug.
Removed the workaround for that bug.
2016-07-15 14:27:42 +02:00
388a059400 Bumped version to 1.3.2 2016-07-15 14:02:01 +02:00
80d2b5b2e7 Move "Share on Cloud" button from image header to menu. 2016-07-15 14:01:21 +02:00
53ab2fc6df Bumped version to 1.3.1 2016-07-14 11:50:19 +02:00
1e2c74e82d Made screenshot the default target for image sharing.
This way the spacebar-menu takes a screenshot of the current area and
shares it. The other targets need a 'name' property set, so those won't
work from the spacebar-menu anyway.

I also added some extra options for the screenshotting, to mirror the
bpy.ops.screen.screenshot() operator options.

The full-window screenshot operator is now also placed in the Window menu.
2016-07-14 11:49:30 +02:00
ecb8f8575f Added missing logger 2016-07-14 11:47:50 +02:00
acd62b4917 Added screenshot functionality 2016-07-14 11:13:09 +02:00
65faeba7b0 Bumped requirement pillarsdk 1.3.0 → 1.4.0 2016-07-13 11:06:51 +02:00
8f8e14b66e Bumped version to 1.3.0 2016-07-12 18:00:00 +02:00
250939dc32 Added custom cloud icon 2016-07-08 17:00:44 +02:00
2e617287fd Remove now-unused PILLAR_WEB_SERVER_URL 2016-07-08 17:00:21 +02:00
36bbead1e1 Handling 413 Request Entity Too Large while uploading synced settings.
Non-subscribers are limited in the file size they can upload.
2016-07-08 12:38:58 +02:00
89a9055aa4 No longer use theatre_link, and show URL in GUI 2016-07-07 17:03:28 +02:00
6339f75406 Fix: added some missing return statements 2016-07-07 16:14:51 +02:00
a9aa961b92 Open browser at the short URL. 2016-07-07 15:43:36 +02:00
4da601be0c Share image after uploading it to Pillar. 2016-07-07 15:19:21 +02:00
3c9e4e2873 Give users the option to open a webbrowser after sharing an image.
The addon now also uses the home project URL from the project itself,
rather than hard-coding it.
2016-07-07 11:43:01 +02:00
4762f0292d Added support for sharing packed images. 2016-07-07 11:09:30 +02:00
959e83229b Allow execution of the file sharing operator
(rather than requiring INVOKE_DEFAULT)
2016-07-06 16:25:31 +02:00
662b6cf221 Choose default target='DATABLOCK' 2016-07-06 16:25:10 +02:00
96616dbdff Always create new nodes on the cloud, and prevent cache issue.
The cache issue: this caused etag mismatches when sharing a file multiple
times using always_create_new_node=False. Even though we don't use this
option right now, it should be easy to enable.
2016-07-06 16:24:55 +02:00
dbbffcc28e Some protection against sharing dirty image datablocks.
We can save dirty files, either to disk or the cloud, but I think that's
a bad idea to:

- Share unsaved data to the cloud; users can assume it's saved
  to disk and close blender, losing their file.
- Save unsaved data first; this can overwrite a file a user
  didn't want to overwrite.

The clearest way is simply to refuse to handle dirty datablocks.
2016-07-06 16:23:33 +02:00
0a1f1972da Support for uploading render results. 2016-07-06 15:48:55 +02:00
c9a92dd5d1 Added start of image sharing.
Sharing an image datablock works, if it has been saved and not packed.
Directly sharing a file, and dirty/packed datablocks are for a future
commit.
2016-07-06 15:20:50 +02:00
1c2def3b84 Moved some code from settings_sync.py to home_project.py and pillar.py 2016-07-05 17:26:26 +02:00
e29b61b649 Using pillarsdk.Node.create_asset_from_file() 2016-07-05 16:47:37 +02:00
1d1c8cf3d6 Bumped version to 1.2.2 2016-06-30 18:43:51 +02:00
fc01e32f0d Added note about restarting Blender 2016-06-30 14:58:26 +02:00
7577b348a5 Prevent writing bytecode when building zip 2016-06-30 14:53:27 +02:00
be99bcb250 Using "your Blender Cloud" instead of "your home project". 2016-06-30 14:43:04 +02:00
2190bd795e Bumped version to 1.2.1 2016-06-29 11:39:43 +02:00
76d1f88c4e Prevent syncing of any file path in the 'Files' tab. 2016-06-29 11:32:25 +02:00
f0b7a0451d Some UI tweaks 2016-06-28 16:55:35 +02:00
6eab5ba0af Work around RuntimeError unregistering the texture browser operator. 2016-06-28 16:41:31 +02:00
d457c77b19 Monkey-patch Requests < 2.6.1 to prevent crash on 2.77a/Mac 2016-06-28 16:07:08 +02:00
ef70d20a77 Bumped version to 1.2.0 in setup.py 2016-06-28 15:34:05 +02:00
db10495e7f Bumped pillarsdk requirement to 1.3.0 2016-06-28 15:32:05 +02:00
586905a183 If there are multiple wheels that match, load the latest one.
This should allow users to upgrade the addon by overwriting an older
version, instead of requiring a remove-and-install sequence.
2016-06-28 15:31:56 +02:00
822c8daf07 Gracefully handle use of sync feature without home project access.
This is for people that aren't part of the AB-testing group, who still
used this version of the addon.
2016-06-28 15:00:14 +02:00
e044607d91 Allow non-subscribers to use Blender Sync 2016-06-28 14:29:51 +02:00
e484d6496c Don't clear report when there was an error getting available Blender versions 2016-06-28 14:29:40 +02:00
78d567793e Depend on Pillar to create the 'Blender Sync' group node. 2016-06-28 14:29:12 +02:00
7e105167c0 Don't sync bookmarks and recent files (for now).
These files can be restored when we allow users to pick what they sync.
2016-06-28 14:28:39 +02:00
d53938e03b Check specific roles for specific addon features. 2016-06-24 15:22:12 +02:00
0f26551368 Texture browser now uses pillar.PillarOperatorMixin too. 2016-06-24 15:00:38 +02:00
645529bf35 Sync: gracefully handle credential sync errors 2016-06-24 14:46:27 +02:00
4d2314e08f Hide some things in the UI except when bpy.app.debug=True 2016-06-24 14:46:13 +02:00
a5df609d95 Bumped version to 1.2.0, and moved from TESTING to OFFICIAL support 2016-06-24 14:02:19 +02:00
e9a08c11b3 Renamed addon to just 'Blender Cloud'
It does more than just be the texture browser, namely Blender Sync!
2016-06-24 13:48:55 +02:00
7bdfa28a3f After pushing, change the 'pull' version to the current version of Blender.
Or to the latest version, if by some mistake somewhere the current push
isn't available after all.
2016-06-24 13:03:10 +02:00
e73e9d3df7 Nice UI and proper refreshing versions & loading settings. 2016-06-24 12:53:49 +02:00
671e9f31fa Nicer UI, and Blender Sync all in one operator. 2016-06-23 19:00:47 +02:00
6de026c8e2 Sync: new operator allows to choose which Blender version to pull.
The user gets a popup with the Blender versions for which they have
synced settings, can select one, and it'll pull those settings in.

There is an issue, though: the PULL action operator doesn't report to
the GUI the way it's written now. Will look at that later.
2016-06-23 11:09:19 +02:00
6470feac7c Moved some functions outside of sync operator 2016-06-23 10:35:30 +02:00
6462561f2d Moved some code around. 2016-06-22 16:48:16 +02:00
2080f92558 Removed now-unused code 2016-06-22 16:26:38 +02:00
a6f5a16583 Don't create folder structure on Cloud when pulling settings.
Instead, an error is shown that there are no synced settings. This will
have to be replaced, allowing the user to select from settings that
are available for other Blender versions.
2016-06-22 16:22:20 +02:00
6f376027e5 Update userpref.blend with machine-local settings before moving it place.
After pulling settings from the Cloud, we update userpref.blend with
machine-local settings before we move it to ~/.config/blender/{ver}/config
2016-06-22 16:04:03 +02:00
2ee9d1ebfa Added callback that can be an 'async def' function 2016-06-22 15:17:35 +02:00
ed02816872 Sync to Blender version specific group node 2016-06-21 17:55:18 +02:00
d100232428 Also mention my fork of CacheControl in requirements.txt 2016-06-21 17:54:58 +02:00
9044bfadb9 Sync pull: Make a backup copy of the files before overwriting them 2016-06-21 16:30:58 +02:00
4cdf2cee9c Also save userprefs after restoring local-only settings. 2016-06-21 16:30:42 +02:00
9c527520a9 Prevent overwriting of certain user preferences.
Those prefs are considered system-specific, such as the temporary
directory and CUDA compute device.
2016-06-17 16:47:32 +02:00
56137c485f Nicer resetting of _loop_kicking_operator_running 2016-06-17 16:27:45 +02:00
eb77461ca0 Removed more caching + added explanation why caching is dangerous here. 2016-06-17 16:22:11 +02:00
884d68ebe8 Let check_credentials return the user ID 2016-06-17 16:22:11 +02:00
36d62082f3 Sync: downloading files from Cloud 2016-06-17 16:22:08 +02:00
af53d61cf2 Sync: upload caching fix
A POST to create a new node didn't invalidate the preceeding GET on
/nodes to find whether the node already exists. As a result, the negative
answer was cached, and new nodes were created even though the node
already existed.
2016-06-17 15:48:22 +02:00
332c32ca9c Allow downloading files with None file_loading/file_loaded/map_type 2016-06-17 15:48:22 +02:00
988dc72ba1 Use Sybren's fork of CacheControl to fix caching issue.
We need my clone until pull request #125 has been merged & released.
See https://github.com/ionrock/cachecontrol/pull/125 for more info.
2016-06-17 15:48:22 +02:00
82c7560c7b Allow easy switching between cached and uncached requests to Pillar 2016-06-17 15:48:22 +02:00
73e2fd77e2 Added reloading of home file after pulling (not implemented pull yet)
Pull is easy, we can already download files from Cloud. Had to jump
through some hoops to make the reload work reliably, though.
2016-06-17 15:48:17 +02:00
483e847ffe Added checking credentials for settings sync 2016-06-17 13:14:10 +02:00
ef822208c8 Use blend_data.is_dirty, seems to be more reliable than is_saved 2016-06-17 13:11:34 +02:00
791b3f480c Uploading setting files to home project works. 2016-06-16 17:19:49 +02:00
efb1456596 Started working on synchronising settings 2016-06-16 16:33:35 +02:00
58785977e7 Easy access to pillar user ID 2016-06-16 16:33:21 +02:00
8a5efc18db Separated async-task-operator code from texture browser GUI code. 2016-06-16 16:33:05 +02:00
b970530f44 Added 'clear_wheels' script 2016-06-16 10:37:28 +02:00
ded05b6ca9 Tweaked debug message 2016-06-15 09:29:09 +02:00
5f5f0d8db9 Prevent double map types in the filename. 2016-05-20 16:20:33 +02:00
30f71ac9fc Fixed typo. I'm a moron. 2016-05-20 14:34:35 +02:00
bdef942b0b Replaced log.warning with debug msg.
We can now list all available projects, so there is no need to warn.
2016-05-20 11:33:26 +02:00
2a0ef39b12 Bumped SDK requirement to 1.2.0 2016-05-20 11:31:45 +02:00
c57a3bc902 Bumped version to 1.1.0 2016-05-18 16:36:56 +02:00
b94998d12e Fall back on texture.properties.files[0].file if texture.picture doesn't exist. 2016-05-18 16:27:04 +02:00
1cd42e246e Use current_path in log 2016-05-18 15:55:08 +02:00
079689a532 Client-side sorting of nodes.
The sorting happens after obtaining the individual nodes, as this is done
in parallel in unpredictable order.
2016-05-18 15:13:44 +02:00
597ba6de1c Use project name in download path, rather than UUID.
Filenames are now also sanitized.
2016-05-18 15:13:29 +02:00
7b59391872 Place map type (col, spec, etc) at end of filename instead of start. 2016-05-18 14:14:38 +02:00
8201ba7691 Fix node type name 2016-05-18 14:12:21 +02:00
8f2b0f8faa Allow querying for multiple node types. 2016-05-18 14:11:49 +02:00
33b52cc8a9 CPU-friendlier by lowering fixed redraw rate.
The GUI is still redrawn on other events, such as mouse move, so it still
responds quickly to that. This is just regarding background updates of the
data model, such as when loading thumbnails.
2016-05-18 13:01:48 +02:00
be46b9cf81 Handling more cases of login/credentials issues 2016-05-18 13:01:04 +02:00
ba4c951d32 Use /bcloud/texture-library end point to fetch texture library projects. 2016-05-18 12:50:51 +02:00
5c7343f8c9 Make sure we can always go up again (except at top level) 2016-05-18 12:17:07 +02:00
64d36818fe Start browsing at project overview, instead of inside one project.
Also moved from using project_uuid and node_uuid to using CloudPath
objects.
2016-05-18 11:57:36 +02:00
07f28d3072 Debug log reason why module can't be imported.
Usually this will be because someone just wants to use the wheel, but
during development this can be caused by other issues, and shouldn't
be silenced.
2016-05-18 11:57:36 +02:00
48ca91a364 Skip nodes of unsupported node_type (instead of raising exception) 2016-05-17 17:30:57 +02:00
7ee052f71b Use project UUID from prefs 2016-05-17 17:30:38 +02:00
2bb859efd9 Increased pillarsdk required version 2016-05-10 15:04:49 +02:00
ac3943fe6c Bumped version to 1.0.1 2016-05-10 15:01:15 +02:00
5eaee872bf Added check for user's roles -- disallow usage by non-subscribers.
This makes it clear from the get-go that users need to subscribe. Otherwise
they'll get unexpected errors once they try to download something.
2016-05-10 14:52:51 +02:00
6ce4399407 Show default mouse cursor, instead of the one belonging to the editor. 2016-05-10 14:33:02 +02:00
bfa375fed0 Bumped pillarsdk requirement to 1.0.0 2016-05-04 14:39:59 +02:00
6d7428c16e Bumped version to 1.0.0 2016-05-04 14:38:44 +02:00
ef7c82666f Updated README 2016-05-04 14:35:01 +02:00
92e27914d1 Refuse to start if the file hasn't been saved. 2016-05-04 14:30:54 +02:00
1d662a0314 Automatic refresh of subclient token. 2016-05-04 14:30:47 +02:00
f3699f651a Removed unused code. 2016-05-04 11:00:26 +02:00
602260329e Don't show the addon in the add->mesh menu 2016-05-03 18:30:06 +02:00
58bae5b3a0 Word-wrap help text in addon prefs 2016-05-03 18:29:43 +02:00
0ccd5cbf97 Remove logging configuration, as this should be done globally.
logging.basicConfig() shouldn't be called by individual addons.
2016-05-03 14:58:46 +02:00
61b8667f3b Include icon PNG files in the bdist zip 2016-05-03 14:11:25 +02:00
ad7e9acb5d Using pillarsdk from pypi 2016-05-03 14:04:15 +02:00
329d830f63 Demoted version to 0.9.0, to give us some incremental releases before 1.0 2016-05-03 13:41:44 +02:00
6be1e4ced9 Read-only Pillar server URL and project_uuid.
These properties have also been removed from the UI. This is to limit the
scope of the addon for the first release, allowing us to test properly.
2016-04-19 11:37:46 +02:00
090a9bc5c6 Added TODO in source code 2016-04-19 11:36:52 +02:00
d77022ee1f Added shortcut to location field in bl_info 2016-04-19 11:36:48 +02:00
b36a5178ba Clarified some self.report({'INFO'}, ...) messages 2016-04-19 10:35:22 +02:00
6450db9c9e Added CloudPath class for easier cloud browsing.
This allows us to have a structured, well-defined way to point at a node
in Pillar (and store its parent nodes).
2016-04-15 10:13:28 +02:00
b70ab9678b BlenderID addon stores subclient token in different key now 2016-04-13 15:37:41 +02:00
5ffeddebd1 Better logging 2016-04-13 15:37:19 +02:00
a6256bd47b Switched to using subclient-specific authentication tokens. 2016-04-12 16:59:34 +02:00
23540f931f Set default URL to actual Pillar URL 2016-04-04 14:52:18 +02:00
29230f09e7 More streamlined interface with Pillar.
Using a semaphore to ensure requests to Pillar aren't too parallel,
so that we can cancel requests faster.
2016-04-01 18:47:06 +02:00
2c4c102302 Use the new Blender ID API 2016-04-01 17:16:29 +02:00
5396fd765d Better reporting when the user is not logged in. 2016-04-01 14:11:30 +02:00
1bb32033b6 Using new Blender ID addon public API 2016-04-01 14:11:12 +02:00
43 changed files with 9606 additions and 1178 deletions

5
.gitignore vendored
View File

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

228
CHANGELOG.md Normal file
View File

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

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

@ -19,6 +19,28 @@ This addon is a *proof of concept* demonstrating the following features:
{F299745} {F299745}
Installing the addon
--------------------
* If you don't have one already, sign up for an account at
the [Blender ID site](https://www.blender.org/id/).
* If you had a previous version of the addon installed, deactivate it
and restart Blender.
* Install and log in with the
[Blender ID addon](https://developer.blender.org/diffusion/BIA/).
* Install the Blender Cloud addon in Blender (User Preferences →
Addons → Install from file...) by pointing it to
`blender_cloud*.addon.zip`.
* Enable the addon in User Preferences → Addons → System.
Running the addon
-----------------
After installing the Blender Cloud addon, press Ctrl+Alt+Shift+A to
activate it (yes, this needs work). Downloaded textures are loaded into
image datablocks. The download location can be configured in the addon
preferences.
Building an installable ZIP file Building an installable ZIP file
-------------------------------- --------------------------------
@ -40,38 +62,6 @@ can find them, or be bundled as wheel files in `blender_cloud/wheels`.
The `python setup.py bdist` command gathers the dependencies and bundles The `python setup.py bdist` command gathers the dependencies and bundles
them as wheel files. them as wheel files.
Installing the addon
--------------------
* To build the addon, run `python setup.py bdist` as described above.
* If you don't have one already, sign up for an account at
the [Blender ID site](https://www.blender.org/id/).
* As a final step, install and log in with the
[Blender ID addon](https://developer.blender.org/diffusion/BIA/).
* Install the Blender Cloud addon in Blender (User Preferences →
Addons → Install from file...) by pointing it to
`dist/blender_cloud*.addon.zip`.
* Enable the addon in User Preferences → Addons → System.
NOTE: The addon requires HTTPS connections, and thus is dependent on
[D1845](https://developer.blender.org/D1845). You can do either of
these:
* Build Blender yourself
* Get a recent copy from the buildbot
* Copy certificate authority certificate PEM file to
`blender/2.77/python/lib/python3.5/site-packages/requests/cacert.pem`.
You can use the same file from your local requests installation, or
use `/etc/ssl/certs/ca-certificates.crt`.
Running the addon
-----------------
After installing the Blender Cloud addon, press Ctrl+Alt+Shift+A to
activate it (yes, this needs work). Downloaded textures are loaded into
image datablocks. The download location can be configured in the addon
preferences.
Design Design
------ ------

52
addon-bundle/README.txt Normal file
View File

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

36
addon-bundle/bundle.sh Executable file
View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

1018
blender_cloud/blendfile.py Normal file

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

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

1073
blender_cloud/pillar.py Normal file → Executable file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

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

View File

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

109
blender_cloud/utils.py Normal file
View File

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

View File

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

8
clear_wheels.sh Executable file
View File

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

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

8
requirements-dev.txt Normal file
View File

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

View File

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

247
setup.py
View File

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

View File

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

25
tests/test_utils.py Normal file
View File

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

22
update_version.sh Executable file
View File

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