Compare commits

...

112 Commits

Author SHA1 Message Date
ffa69f3f7c Bumped version to 1.16 2020-03-03 10:37:52 +01:00
e62e47d2ab Fixed Windows compatibility with Shaman URL handling 2020-03-03 10:37:44 +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 c457767edf,
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 c457767edf. 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 a69f4d3fd9.
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
29 changed files with 1879 additions and 616 deletions

View File

@@ -1,5 +1,99 @@
# Blender Cloud changelog
## 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.

View File

@@ -21,7 +21,7 @@
bl_info = {
'name': 'Blender Cloud',
"author": "Sybren A. Stüvel, Francesco Siddi, Inês Almeida, Antony Riakiotakis",
'version': (1, 9, 2),
'version': (1, 16),
'blender': (2, 80, 0),
'location': 'Addon Preferences panel, and Ctrl+Shift+Alt+A anywhere for texture browser',
'description': 'Texture library browser and Blender Sync. Requires the Blender ID addon '

View File

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

View File

@@ -23,6 +23,7 @@ import traceback
import concurrent.futures
import logging
import gc
import typing
import bpy
@@ -238,7 +239,7 @@ class AsyncModalOperatorMixin:
self._stop_async_task()
context.window_manager.event_timer_remove(self.timer)
def _new_async_task(self, async_task: asyncio.coroutine, future: asyncio.Future = None):
def _new_async_task(self, async_task: typing.Coroutine, future: asyncio.Future = None):
"""Stops the currently running async task, and starts another one."""
self.log.debug('Setting up a new task %r, so any existing task must be stopped', async_task)

View File

@@ -47,9 +47,15 @@ if "bpy" in locals():
pillar = importlib.reload(pillar)
async_loop = importlib.reload(async_loop)
blender = importlib.reload(blender)
compatibility = importlib.reload(compatibility)
else:
from . import draw
from .. import pillar, async_loop, blender
import bpy
if bpy.app.version < (2, 80):
from . import draw_27 as draw
else:
from . import draw
from .. import pillar, async_loop, blender, compatibility
import bpy
import pillarsdk
@@ -58,6 +64,7 @@ from pillarsdk.projects import Project
from pillarsdk import exceptions as sdk_exceptions
from bpy.types import Operator, Panel, AddonPreferences
import bl_ui.space_sequencer
log = logging.getLogger(__name__)
@@ -174,10 +181,11 @@ class AttractPollMixin:
return attract_is_active
class AttractToolsPanel(AttractPollMixin, Panel):
class ATTRACT_PT_tools(AttractPollMixin, Panel):
bl_label = 'Attract'
bl_space_type = 'SEQUENCE_EDITOR'
bl_region_type = 'UI'
bl_category = 'Strip'
def draw_header(self, context):
strip = active_strip(context)
@@ -189,66 +197,69 @@ class AttractToolsPanel(AttractPollMixin, Panel):
layout = self.layout
strip_types = {'MOVIE', 'IMAGE', 'META', 'COLOR'}
selshots = list(selected_shots(context))
if strip and strip.type in strip_types and strip.atc_object_id:
if len(selshots) > 1:
noun = '%i Shots' % len(selshots)
else:
noun = 'This Shot'
if strip.atc_object_id_conflict:
warnbox = layout.box()
warnbox.alert = True
warnbox.label(text='Warning: This shot is linked to multiple sequencer strips.',
icon='ERROR')
layout.prop(strip, 'atc_name', text='Name')
layout.prop(strip, 'atc_status', text='Status')
# Create a special sub-layout for read-only properties.
ro_sub = layout.column(align=True)
ro_sub.enabled = False
ro_sub.prop(strip, 'atc_description', text='Description')
ro_sub.prop(strip, 'atc_notes', text='Notes')
if strip.atc_is_synced:
sub = layout.column(align=True)
row = sub.row(align=True)
if bpy.ops.attract.submit_selected.poll():
row.operator('attract.submit_selected',
text='Submit %s' % noun,
icon='TRIA_UP')
else:
row.operator(ATTRACT_OT_submit_all.bl_idname)
row.operator(AttractShotFetchUpdate.bl_idname,
text='', icon='FILE_REFRESH')
row.operator(ATTRACT_OT_shot_open_in_browser.bl_idname,
text='', icon='WORLD')
row.operator(ATTRACT_OT_copy_id_to_clipboard.bl_idname,
text='', icon='COPYDOWN')
sub.operator(ATTRACT_OT_make_shot_thumbnail.bl_idname,
text='Render Thumbnail for %s' % noun,
icon='RENDER_STILL')
# Group more dangerous operations.
dangerous_sub = layout.split(**blender.factor(0.6), align=True)
dangerous_sub.operator('attract.strip_unlink',
text='Unlink %s' % noun,
icon='PANEL_CLOSE')
dangerous_sub.operator(AttractShotDelete.bl_idname,
text='Delete %s' % noun,
icon='CANCEL')
self._draw_attractstrip_buttons(context, strip)
elif context.selected_sequences:
if len(context.selected_sequences) > 1:
noun = 'Selected Strips'
else:
noun = 'This Strip'
layout.operator(AttractShotSubmitSelected.bl_idname,
layout.operator(ATTRACT_OT_submit_selected.bl_idname,
text='Submit %s as New Shot' % noun)
layout.operator('attract.shot_relink')
else:
layout.operator(ATTRACT_OT_submit_all.bl_idname)
layout.operator(ATTRACT_OT_project_open_in_browser.bl_idname, icon='WORLD')
def _draw_attractstrip_buttons(self, context, strip):
"""Draw buttons when selected strips are Attract shots."""
layout = self.layout
selshots = list(selected_shots(context))
if len(selshots) > 1:
noun = '%i Shots' % len(selshots)
else:
noun = 'This Shot'
if strip.atc_object_id_conflict:
warnbox = layout.box()
warnbox.alert = True
warnbox.label(text='Warning: This shot is linked to multiple sequencer strips.',
icon='ERROR')
layout.prop(strip, 'atc_name', text='Name')
layout.prop(strip, 'atc_status', text='Status')
# Create a special sub-layout for read-only properties.
ro_sub = layout.column(align=True)
ro_sub.enabled = False
ro_sub.prop(strip, 'atc_description', text='Description')
ro_sub.prop(strip, 'atc_notes', text='Notes')
if strip.atc_is_synced:
sub = layout.column(align=True)
row = sub.row(align=True)
if bpy.ops.attract.submit_selected.poll():
row.operator('attract.submit_selected',
text='Submit %s' % noun,
icon='TRIA_UP')
else:
row.operator(ATTRACT_OT_submit_all.bl_idname)
row.operator(ATTRACT_OT_shot_fetch_update.bl_idname,
text='', icon='FILE_REFRESH')
row.operator(ATTRACT_OT_shot_open_in_browser.bl_idname,
text='', icon='WORLD')
row.operator(ATTRACT_OT_copy_id_to_clipboard.bl_idname,
text='', icon='COPYDOWN')
sub.operator(ATTRACT_OT_make_shot_thumbnail.bl_idname,
text='Render Thumbnail for %s' % noun,
icon='RENDER_STILL')
# Group more dangerous operations.
dangerous_sub = layout.split(**compatibility.factor(0.6), align=True)
dangerous_sub.operator('attract.strip_unlink',
text='Unlink %s' % noun,
icon='PANEL_CLOSE')
dangerous_sub.operator(ATTRACT_OT_shot_delete.bl_idname,
text='Delete %s' % noun,
icon='CANCEL')
class AttractOperatorMixin(AttractPollMixin):
@@ -379,7 +390,7 @@ class AttractOperatorMixin(AttractPollMixin):
draw.tag_redraw_all_sequencer_editors()
class AttractShotFetchUpdate(AttractOperatorMixin, Operator):
class ATTRACT_OT_shot_fetch_update(AttractOperatorMixin, Operator):
bl_idname = "attract.shot_fetch_update"
bl_label = "Fetch Update From Attract"
bl_description = 'Update status, description & notes from Attract'
@@ -398,7 +409,8 @@ class AttractShotFetchUpdate(AttractOperatorMixin, Operator):
return {'FINISHED'}
class AttractShotRelink(AttractShotFetchUpdate):
@compatibility.convert_properties
class ATTRACT_OT_shot_relink(AttractOperatorMixin, Operator):
bl_idname = "attract.shot_relink"
bl_label = "Relink With Attract"
@@ -467,7 +479,8 @@ class ATTRACT_OT_shot_open_in_browser(AttractOperatorMixin, Operator):
return {'FINISHED'}
class AttractShotDelete(AttractOperatorMixin, Operator):
@compatibility.convert_properties
class ATTRACT_OT_shot_delete(AttractOperatorMixin, Operator):
bl_idname = 'attract.shot_delete'
bl_label = 'Delete Shot'
bl_description = 'Remove this shot from Attract'
@@ -522,7 +535,7 @@ class AttractShotDelete(AttractOperatorMixin, Operator):
col.prop(self, 'confirm', text="I hereby confirm: delete %s from The Edit." % noun)
class AttractStripUnlink(AttractOperatorMixin, Operator):
class ATTRACT_OT_strip_unlink(AttractOperatorMixin, Operator):
bl_idname = 'attract.strip_unlink'
bl_label = 'Unlink Shot From This Strip'
bl_description = 'Remove Attract props from the selected strip(s)'
@@ -566,7 +579,7 @@ class AttractStripUnlink(AttractOperatorMixin, Operator):
return {'FINISHED'}
class AttractShotSubmitSelected(AttractOperatorMixin, Operator):
class ATTRACT_OT_submit_selected(AttractOperatorMixin, Operator):
bl_idname = 'attract.submit_selected'
bl_label = 'Submit All Selected'
bl_description = 'Submits all selected strips to Attract'
@@ -630,8 +643,7 @@ class ATTRACT_OT_open_meta_blendfile(AttractOperatorMixin, Operator):
@classmethod
def poll(cls, context):
return AttractOperatorMixin.poll(context) and \
bool(any(cls.filename_from_metadata(s) for s in context.selected_sequences))
return bool(any(cls.filename_from_metadata(s) for s in context.selected_sequences))
@staticmethod
def filename_from_metadata(strip):
@@ -906,25 +918,63 @@ class ATTRACT_OT_copy_id_to_clipboard(AttractOperatorMixin, Operator):
return {'FINISHED'}
def draw_strip_movie_meta(self, context):
strip = active_strip(context)
if not strip:
return
@compatibility.convert_properties
class ATTRACT_OT_project_open_in_browser(Operator):
bl_idname = 'attract.project_open_in_browser'
bl_label = 'Open Project in Browser'
bl_description = 'Opens a webbrowser to show the project in Attract'
meta = strip.get('metadata', None)
if not meta:
return None
project_id = bpy.props.StringProperty(name='Project ID', default='')
box = self.layout.column(align=True)
row = box.row(align=True)
fname = meta.get('BLEND_FILE', None) or None
if fname:
row.label(text='Original Blendfile: %s' % fname)
row.operator(ATTRACT_OT_open_meta_blendfile.bl_idname,
text='', icon='FILE_BLEND')
sfra = meta.get('START_FRAME', '?')
efra = meta.get('END_FRAME', '?')
box.label(text='Original Frame Range: %s-%s' % (sfra, efra))
def execute(self, context):
import webbrowser
import urllib.parse
import pillarsdk
from ..pillar import sync_call
from ..blender import PILLAR_WEB_SERVER_URL, preferences
if not self.project_id:
self.project_id = preferences().project.project
project = sync_call(pillarsdk.Project.find, self.project_id, {'projection': {'url': True}})
if log.isEnabledFor(logging.DEBUG):
import pprint
log.debug('found project: %s', pprint.pformat(project.to_dict()))
url = urllib.parse.urljoin(PILLAR_WEB_SERVER_URL, 'attract/' + project.url)
webbrowser.open_new_tab(url)
self.report({'INFO'}, 'Opened a browser at %s' % url)
return {'FINISHED'}
class ATTRACT_PT_strip_metadata(bl_ui.space_sequencer.SequencerButtonsPanel, Panel):
bl_label = "Metadata"
bl_parent_id = "SEQUENCER_PT_source"
bl_category = "Strip"
bl_options = {'DEFAULT_CLOSED'}
def draw(self, context):
strip = active_strip(context)
if not strip:
return
meta = strip.get('metadata', None)
if not meta:
return None
box = self.layout.column(align=True)
row = box.row(align=True)
fname = meta.get('BLEND_FILE', None) or None
if fname:
row.label(text='Original Blendfile: %s' % fname)
row.operator(ATTRACT_OT_open_meta_blendfile.bl_idname,
text='', icon='FILE_BLEND')
sfra = meta.get('START_FRAME', '?')
efra = meta.get('END_FRAME', '?')
box.label(text='Original Frame Range: %s-%s' % (sfra, efra))
def activate():
@@ -955,7 +1005,9 @@ def deactivate():
pass
_rna_classes = [cls for cls in locals() if isinstance(cls, type) and hasattr(cls, 'bl_rna')]
_rna_classes = [cls for cls in locals().values()
if isinstance(cls, type) and cls.__name__.startswith('ATTRACT')]
log.info('RNA classes:\n%s', '\n'.join([repr(cls) for cls in _rna_classes]))
def register():
@@ -981,8 +1033,6 @@ def register():
name="Status")
bpy.types.Sequence.atc_order = bpy.props.IntProperty(name="Order")
bpy.types.SEQUENCER_PT_edit.append(draw_strip_movie_meta)
for cls in _rna_classes:
bpy.utils.register_class(cls)

View File

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

View File

@@ -0,0 +1,182 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
import bpy
import logging
import collections
log = logging.getLogger(__name__)
strip_status_colour = {
None: (0.7, 0.7, 0.7),
'approved': (0.6392156862745098, 0.8784313725490196, 0.30196078431372547),
'final': (0.9058823529411765, 0.9607843137254902, 0.8274509803921568),
'in_progress': (1.0, 0.7450980392156863, 0.0),
'on_hold': (0.796078431372549, 0.6196078431372549, 0.08235294117647059),
'review': (0.8941176470588236, 0.9607843137254902, 0.9764705882352941),
'todo': (1.0, 0.5019607843137255, 0.5019607843137255)
}
CONFLICT_COLOUR = (0.576, 0.118, 0.035) # RGB tuple
def get_strip_rectf(strip):
# Get x and y in terms of the grid's frames and channels
x1 = strip.frame_final_start
x2 = strip.frame_final_end
y1 = strip.channel + 0.2
y2 = strip.channel - 0.2 + 1
return x1, y1, x2, y2
def draw_underline_in_strip(strip_coords, pixel_size_x, color):
from bgl import glColor4f, glRectf, glEnable, glDisable, GL_BLEND
import bgl
context = bpy.context
# Strip coords
s_x1, s_y1, s_x2, s_y2 = strip_coords
# be careful not to draw over the current frame line
cf_x = context.scene.frame_current_final
bgl.glPushAttrib(bgl.GL_COLOR_BUFFER_BIT | bgl.GL_LINE_BIT)
glColor4f(*color)
glEnable(GL_BLEND)
bgl.glLineWidth(2)
bgl.glBegin(bgl.GL_LINES)
bgl.glVertex2f(s_x1, s_y1)
if s_x1 < cf_x < s_x2:
# Bad luck, the line passes our strip
bgl.glVertex2f(cf_x - pixel_size_x, s_y1)
bgl.glVertex2f(cf_x + pixel_size_x, s_y1)
bgl.glVertex2f(s_x2, s_y1)
bgl.glEnd()
bgl.glPopAttrib()
def draw_strip_conflict(strip_coords, pixel_size_x):
"""Draws conflicting states between strips."""
import bgl
s_x1, s_y1, s_x2, s_y2 = strip_coords
bgl.glPushAttrib(bgl.GL_COLOR_BUFFER_BIT | bgl.GL_LINE_BIT)
# Always draw the full rectangle, the conflict should be resolved and thus stand out.
bgl.glColor3f(*CONFLICT_COLOUR)
bgl.glLineWidth(2)
bgl.glBegin(bgl.GL_LINE_LOOP)
bgl.glVertex2f(s_x1, s_y1)
bgl.glVertex2f(s_x2, s_y1)
bgl.glVertex2f(s_x2, s_y2)
bgl.glVertex2f(s_x1, s_y2)
bgl.glEnd()
bgl.glPopAttrib()
def draw_callback_px():
context = bpy.context
if not context.scene.sequence_editor:
return
from . import shown_strips
region = context.region
xwin1, ywin1 = region.view2d.region_to_view(0, 0)
xwin2, ywin2 = region.view2d.region_to_view(region.width, region.height)
one_pixel_further_x, one_pixel_further_y = region.view2d.region_to_view(1, 1)
pixel_size_x = one_pixel_further_x - xwin1
strips = shown_strips(context)
for strip in strips:
if not strip.atc_object_id:
continue
# Get corners (x1, y1), (x2, y2) of the strip rectangle in px region coords
strip_coords = get_strip_rectf(strip)
# check if any of the coordinates are out of bounds
if strip_coords[0] > xwin2 or strip_coords[2] < xwin1 or strip_coords[1] > ywin2 or \
strip_coords[3] < ywin1:
continue
# Draw
status = strip.atc_status
if status in strip_status_colour:
color = strip_status_colour[status]
else:
color = strip_status_colour[None]
alpha = 1.0 if strip.atc_is_synced else 0.5
draw_underline_in_strip(strip_coords, pixel_size_x, color + (alpha,))
if strip.atc_is_synced and strip.atc_object_id_conflict:
draw_strip_conflict(strip_coords, pixel_size_x)
def tag_redraw_all_sequencer_editors():
context = bpy.context
# Py cant access notifiers
for window in context.window_manager.windows:
for area in window.screen.areas:
if area.type == 'SEQUENCE_EDITOR':
for region in area.regions:
if region.type == 'WINDOW':
region.tag_redraw()
# This is a list so it can be changed instead of set
# if it is only changed, it does not have to be declared as a global everywhere
cb_handle = []
def callback_enable():
if cb_handle:
return
cb_handle[:] = bpy.types.SpaceSequenceEditor.draw_handler_add(
draw_callback_px, (), 'WINDOW', 'POST_VIEW'),
tag_redraw_all_sequencer_editors()
def callback_disable():
if not cb_handle:
return
try:
bpy.types.SpaceSequenceEditor.draw_handler_remove(cb_handle[0], 'WINDOW')
except ValueError:
# Thrown when already removed.
pass
cb_handle.clear()
tag_redraw_all_sequencer_editors()

View File

@@ -30,7 +30,7 @@ from bpy.types import AddonPreferences, Operator, WindowManager, Scene, Property
from bpy.props import StringProperty, EnumProperty, PointerProperty, BoolProperty, IntProperty
import rna_prop_ui
from . import pillar, async_loop, flamenco, project_specific
from . import compatibility, pillar, async_loop, flamenco, project_specific
from .utils import pyside_cache, redraw
PILLAR_WEB_SERVER_URL = os.environ.get('BCLOUD_SERVER', 'https://cloud.blender.org/')
@@ -41,18 +41,6 @@ log = logging.getLogger(__name__)
icons = None
@functools.lru_cache()
def factor(factor: float) -> dict:
"""Construct keyword argument for UILayout.split().
On Blender 2.8 this returns {'factor': factor}, and on earlier Blenders it returns
{'percentage': factor}.
"""
if bpy.app.version < (2, 80, 0):
return {'percentage': factor}
return {'factor': factor}
@pyside_cache('version')
def blender_syncable_versions(self, context):
"""Returns the list of items used by SyncStatusProperties.version EnumProperty."""
@@ -64,6 +52,7 @@ def blender_syncable_versions(self, context):
return [(v, v, '') for v in versions]
@compatibility.convert_properties
class SyncStatusProperties(PropertyGroup):
status = EnumProperty(
items=[
@@ -151,6 +140,7 @@ def project_extensions(project_id) -> set:
return set(proj.get('enabled_for', ()))
@compatibility.convert_properties
class BlenderCloudProjectGroup(PropertyGroup):
status = EnumProperty(
items=[
@@ -180,6 +170,7 @@ class BlenderCloudProjectGroup(PropertyGroup):
project_specific.handle_project_update()
@compatibility.convert_properties
class BlenderCloudPreferences(AddonPreferences):
bl_idname = ADDON_NAME
@@ -219,8 +210,8 @@ class BlenderCloudPreferences(AddonPreferences):
flamenco_manager = PointerProperty(type=flamenco.FlamencoManagerGroup)
flamenco_exclude_filter = StringProperty(
name='File Exclude Filter',
description='Filter like "*.abc;*.mkv" to prevent certain files to be packed '
'into the output directory',
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,
)
@@ -248,11 +239,27 @@ class BlenderCloudPreferences(AddonPreferences):
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):
import textwrap
@@ -310,7 +317,7 @@ class BlenderCloudPreferences(AddonPreferences):
bss = context.window_manager.blender_sync_status
bsync_box = layout.box()
bsync_box.enabled = msg_icon != 'ERROR'
row = bsync_box.row().split(**factor(0.33))
row = bsync_box.row().split(**compatibility.factor(0.33))
row.label(text='Blender Sync with Blender Cloud', icon_value=icon('CLOUD'))
icon_for_level = {
@@ -353,7 +360,7 @@ class BlenderCloudPreferences(AddonPreferences):
layout.enabled = bss.status in {'NONE', 'IDLE'}
buttons = layout.column()
row_buttons = buttons.row().split(**factor(0.5))
row_buttons = buttons.row().split(**compatibility.factor(0.5))
row_push = row_buttons.row()
row_pull = row_buttons.row(align=True)
@@ -362,21 +369,20 @@ class BlenderCloudPreferences(AddonPreferences):
icon='TRIA_UP').action = 'PUSH'
versions = bss.available_blender_versions
version = bss.version
if bss.status in {'NONE', 'IDLE'}:
if not versions or not version:
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' % version,
text='Load %s settings' % bss.version,
icon='TRIA_DOWN')
props.action = 'PULL'
props.blender_version = version
props.blender_version = bss.version
row_pull.operator('pillar.sync',
text='',
icon='DOTSDOWN').action = 'SELECT'
icon=compatibility.SYNC_SELECT_VERSION_ICON).action = 'SELECT'
else:
row_pull.label(text='Cloud Sync is running.')
@@ -389,7 +395,7 @@ class BlenderCloudPreferences(AddonPreferences):
projects = bcp.available_projects
project = bcp.project
if bcp.status in {'NONE', 'IDLE'}:
if not projects or not project:
if not projects:
row_buttons.operator('pillar.projects',
text='Find project to load',
icon='FILE_REFRESH')
@@ -424,12 +430,12 @@ class BlenderCloudPreferences(AddonPreferences):
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 = flamenco_box.split(**compatibility.factor(0.32), align=True)
manager_split.label(text='Manager:')
manager_box = manager_split.row(align=True)
if bcp.status in {'NONE', 'IDLE'}:
if not bcp.available_managers or not bcp.manager:
if not bcp.available_managers:
manager_box.operator('flamenco.managers',
text='Find Flamenco Managers',
icon='FILE_REFRESH')
@@ -441,7 +447,7 @@ class BlenderCloudPreferences(AddonPreferences):
else:
manager_box.label(text='Fetching available managers.')
path_split = flamenco_box.split(**factor(0.32), align=True)
path_split = flamenco_box.split(**compatibility.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='')
@@ -449,7 +455,7 @@ class BlenderCloudPreferences(AddonPreferences):
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 = job_output_box.split(**compatibility.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='')
@@ -457,7 +463,7 @@ class BlenderCloudPreferences(AddonPreferences):
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 = job_output_box.split(**compatibility.factor(0.32), align=True)
prop_split.label(text='Strip Components:')
prop_split.prop(self, 'flamenco_job_output_strip_components', text='')
@@ -473,7 +479,9 @@ class BlenderCloudPreferences(AddonPreferences):
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(pillar.PillarOperatorMixin,
@@ -539,6 +547,7 @@ class PILLAR_OT_subscribe(Operator):
return {'FINISHED'}
@compatibility.convert_properties
class PILLAR_OT_project_open_in_browser(Operator):
bl_idname = 'pillar.project_open_in_browser'
bl_label = 'Open in Browser'
@@ -652,8 +661,16 @@ class PILLAR_PT_image_custom_properties(rna_prop_ui.PropertyPanel, bpy.types.Pan
_property_type = bpy.types.Image
def ctx_preferences():
"""Returns bpy.context.preferences in a 2.79-compatible way."""
try:
return bpy.context.preferences
except AttributeError:
return bpy.context.user_preferences
def preferences() -> BlenderCloudPreferences:
return bpy.context.user_preferences.addons[ADDON_NAME].preferences
return ctx_preferences().addons[ADDON_NAME].preferences
def load_custom_icons():

View File

@@ -0,0 +1,70 @@
"""Compatibility functions to support Blender 2.79 and 2.80+ in one code base."""
import functools
import bpy
if bpy.app.version < (2, 80):
SYNC_SELECT_VERSION_ICON = 'DOTSDOWN'
else:
SYNC_SELECT_VERSION_ICON = 'DOWNARROW_HLT'
# Get references to all property definition functions in bpy.props,
# so that they can be used to replace 'x = IntProperty()' to 'x: IntProperty()'
# dynamically when working on Blender 2.80+
__all_prop_funcs = {
getattr(bpy.props, propname)
for propname in dir(bpy.props)
if propname.endswith('Property')
}
def convert_properties(class_):
"""Class decorator to avoid warnings in Blender 2.80+
This decorator replaces property definitions like this:
someprop = bpy.props.IntProperty()
to annotations, as introduced in Blender 2.80:
someprop: bpy.props.IntProperty()
No-op if running on Blender 2.79 or older.
"""
if bpy.app.version < (2, 80):
return class_
if not hasattr(class_, '__annotations__'):
class_.__annotations__ = {}
attrs_to_delete = []
for name, value in class_.__dict__.items():
if not isinstance(value, tuple) or len(value) != 2:
continue
prop_func, kwargs = value
if prop_func not in __all_prop_funcs:
continue
# This is a property definition, replace it with annotation.
attrs_to_delete.append(name)
class_.__annotations__[name] = value
for attr_name in attrs_to_delete:
delattr(class_, attr_name)
return class_
@functools.lru_cache()
def factor(factor: float) -> dict:
"""Construct keyword argument for UILayout.split().
On Blender 2.8 this returns {'factor': factor}, and on earlier Blenders it returns
{'percentage': factor}.
"""
if bpy.app.version < (2, 80, 0):
return {'percentage': factor}
return {'factor': factor}

View File

@@ -34,18 +34,19 @@ if "bpy" in locals():
bat_interface = importlib.reload(bat_interface)
sdk = importlib.reload(sdk)
blender = importlib.reload(blender)
compatibility = importlib.reload(compatibility)
except NameError:
from . import bat_interface, sdk
from .. import blender
from .. import blender, compatibility
else:
from . import bat_interface, sdk
from .. import blender
from .. import blender, compatibility
import bpy
from bpy.types import AddonPreferences, Operator, WindowManager, Scene, PropertyGroup
from bpy.props import StringProperty, EnumProperty, PointerProperty, BoolProperty, IntProperty
from .. import async_loop, pillar, project_specific
from .. import async_loop, pillar, project_specific, utils
from ..utils import pyside_cache, redraw
log = logging.getLogger(__name__)
@@ -53,6 +54,37 @@ log = logging.getLogger(__name__)
# Global flag used to determine whether panels etc. can be drawn.
flamenco_is_active = False
# 'image' file formats that actually produce a video.
VIDEO_FILE_FORMATS = {'FFMPEG', 'AVI_RAW', 'AVI_JPEG'}
# Video container name (from bpy.context.scene.render.ffmpeg.format) to file
# extension mapping. Any container name not listed here will be converted to
# lower case and prepended with a period. This is basically copied from
# Blender's source, get_file_extensions() in writeffmpeg.c.
VIDEO_CONTAINER_TO_EXTENSION = {
'QUICKTIME': '.mov',
'MPEG1': '.mpg',
'MPEG2': '.dvd',
'MPEG4': '.mp4',
'OGG': '.ogv',
'FLASH': '.flv',
}
SHAMAN_URL_SCHEMES = {'shaman://', 'shaman+http://', 'shaman+https://'}
def scene_sample_count(scene) -> int:
"""Determine nr of render samples for this scene."""
if scene.cycles.progressive == 'BRANCHED_PATH':
samples = scene.cycles.aa_samples
else:
samples = scene.cycles.samples
if scene.cycles.use_square_samples:
samples **= 2
return samples
@pyside_cache('manager')
def available_managers(self, context):
@@ -66,12 +98,54 @@ def available_managers(self, context):
return [(p['_id'], p['name'], '') for p in mngrs]
def manager_updated(self: 'FlamencoManagerGroup', context):
from ..blender import preferences
flamenco_manager_id = self.manager
log.debug('manager updated to %r', flamenco_manager_id)
prefs = preferences()
project_id = prefs.project.project
ps = prefs.get('project_settings', {}).get(project_id, {})
# Load per-project, per-manager settings for the current Manager.
try:
pppm = ps['flamenco_managers_settings'][flamenco_manager_id]
except KeyError:
# No settings for this manager, so nothing to do.
return
with project_specific.mark_as_loading():
project_specific.update_preferences(prefs,
project_specific.FLAMENCO_PER_PROJECT_PER_MANAGER,
pppm)
def silently_quit_blender():
"""Quit Blender without any confirmation popup."""
try:
prefs = bpy.context.preferences
except AttributeError:
# Backward compatibility with Blender < 2.80
prefs = bpy.context.user_preferences
try:
prefs.view.use_save_prompt = False
except AttributeError:
# Backward compatibility with Blender < 2.80
prefs.view.use_quit_dialog = False
bpy.ops.wm.quit_blender()
@compatibility.convert_properties
class FlamencoManagerGroup(PropertyGroup):
manager = EnumProperty(
items=available_managers,
name='Flamenco Manager',
description='Which Flamenco Manager to use for jobs',
update=project_specific.store,
update=manager_updated,
)
status = EnumProperty(
@@ -127,18 +201,26 @@ class FLAMENCO_OT_fmanagers(async_loop.AsyncModalOperatorMixin,
from ..blender import preferences
prefs = preferences()
mypref = self.mypref
self.log.info('Going to fetch managers for user %s', self.user_id)
self.mypref.status = 'FETCHING'
mypref.status = 'FETCHING'
params = {'where': '{"projects" : "%s"}' % prefs.project.project}
managers = await pillar_call(Manager.all, params)
# We need to convert to regular dicts before storing in ID properties.
# Also don't store more properties than we need.
as_list = [{'_id': p['_id'], 'name': p['name']} for p in managers['_items']]
as_list = [{'_id': man['_id'], 'name': man['name']}
for man in managers['_items']]
current_manager = mypref.manager
mypref.available_managers = as_list
# Prevent warnings about the current manager not being in the EnumProperty items.
if as_list and not any(man['_id'] == current_manager for man in as_list):
mypref.manager = as_list[0]['_id']
self.mypref.available_managers = as_list
self.quit()
def quit(self):
@@ -146,6 +228,53 @@ class FLAMENCO_OT_fmanagers(async_loop.AsyncModalOperatorMixin,
super().quit()
def guess_output_file_extension(output_format: str, scene) -> str:
"""Return extension, including period, like '.png' or '.mkv'."""
if output_format not in VIDEO_FILE_FORMATS:
return scene.render.file_extension
container = scene.render.ffmpeg.format
try:
return VIDEO_CONTAINER_TO_EXTENSION[container]
except KeyError:
return '.' + container.lower()
def is_shaman_url(path_or_url: str) -> bool:
"""Check whether the given string is a Shaman URL.
:param path_or_url: A string that may represent a filesystem path or a URL.
May not be a pathlib.Path, as that would break URL notation on Windows.
"""
assert isinstance(path_or_url, str)
return any(path_or_url.startswith(scheme) for scheme in SHAMAN_URL_SCHEMES)
def is_file_inside_job_storage(prefs, current_file: typing.Union[str, Path]) -> bool:
"""Check whether current blend file is inside the storage path.
:return: True when 'current_file' is inside the Flamenco
job storage directory already. In this case it won't be
BAT-packed, as it's assumed the job storage dir is
accessible by the workers already.
"""
if isinstance(current_file, str):
# Shaman URLs are always remote, so the current file cannot be in there.
if is_shaman_url(current_file):
return False
current_file = Path(current_file)
flamenco_job_file_path = Path(prefs.flamenco_job_file_path).absolute().resolve()
current_file = current_file.absolute().resolve()
try:
current_file.relative_to(flamenco_job_file_path)
except ValueError:
return False
return True
@compatibility.convert_properties
class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin,
pillar.AuthenticatedPillarOperatorMixin,
FlamencoPollMixin,
@@ -158,6 +287,8 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin,
stop_upon_exception = True
log = logging.getLogger('%s.FLAMENCO_OT_render' % __name__)
quit_after_submit = BoolProperty()
async def async_execute(self, context):
# Refuse to start if the file hasn't been saved. It's okay if
# it's dirty, but we do need a filename and a location.
@@ -189,11 +320,6 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin,
return
self.log.info('Will output render files to %s', render_output)
# BAT-pack the files to the destination directory.
outdir, outfile, missing_sources = await self.bat_pack(filepath)
if not outfile:
return
# Fetch Manager for doing path replacement.
self.log.info('Going to fetch manager %s', self.user_id)
prefs = preferences()
@@ -207,51 +333,92 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin,
self.quit()
return
# Create the job at Flamenco Server.
context.window_manager.flamenco_status = 'COMMUNICATING'
# Construct as much of the job settings as we can before BAT-packing.
# Validation should happen as soon as possible (BAT-packing can take minutes).
frame_range = scene.flamenco_render_frame_range.strip() or scene_frame_range(context)
settings = {'blender_cmd': '{blender}',
'chunk_size': scene.flamenco_render_fchunk_size,
'filepath': manager.replace_path(outfile),
'frames': frame_range,
'render_output': manager.replace_path(render_output),
# Used for FFmpeg combining output frames into a video.
'fps': scene.render.fps / scene.render.fps_base,
'extract_audio': scene.render.ffmpeg.audio_codec != 'NONE',
}
# Add extra settings specific to the job type
if scene.flamenco_render_job_type == 'blender-render-progressive':
if scene.cycles.progressive == 'BRANCHED_PATH':
samples = scene.cycles.aa_samples
else:
samples = scene.cycles.samples
if scene.cycles.use_square_samples:
samples **= 2
settings['cycles_num_chunks'] = scene.flamenco_render_schunk_count
samples = scene_sample_count(scene)
settings['cycles_sample_cap'] = scene.flamenco_render_chunk_sample_cap
settings['cycles_sample_count'] = samples
settings['format'] = 'EXR'
settings['format'] = 'OPEN_EXR'
try:
job_info = await create_job(self.user_id,
prefs.project.project,
manager_id,
scene.flamenco_render_job_type,
settings,
'Render %s' % filepath.name,
priority=scene.flamenco_render_job_priority,
start_paused=scene.flamenco_start_paused)
except Exception as ex:
self.report({'ERROR'}, 'Error creating Flamenco job: %s' % ex)
# Let Flamenco Server know whether we'll output images or video.
output_format = settings.get('format') or scene.render.image_settings.file_format
if output_format in VIDEO_FILE_FORMATS:
settings['images_or_video'] = 'video'
else:
settings['images_or_video'] = 'images'
# Always pass the file format, even though it won't be
# necessary for the actual render command (the blend file
# already has the correct setting). It's used by other
# commands, such as FFmpeg combining output frames into
# a video.
#
# Note that this might be overridden above when the job type
# requires a specific file format.
settings.setdefault('format', scene.render.image_settings.file_format)
settings['output_file_extension'] = guess_output_file_extension(output_format, scene)
if not self.validate_job_settings(context, settings):
self.quit()
return
# Store the job ID in a file in the output dir.
with open(str(outdir / 'jobinfo.json'), 'w', encoding='utf8') as outfile:
import json
# Create the job at Flamenco Server.
context.window_manager.flamenco_status = 'COMMUNICATING'
project_id = prefs.project.project
job_name = self._make_job_name(filepath)
try:
job_info = await create_job(self.user_id,
project_id,
manager_id,
scene.flamenco_render_job_type,
settings,
job_name,
priority=scene.flamenco_render_job_priority,
start_paused=scene.flamenco_start_paused)
except Exception as ex:
message = str(ex)
if isinstance(ex, pillarsdk.exceptions.BadRequest):
payload = ex.response.json()
try:
message = payload['_error']['message']
except KeyError:
pass
self.log.exception('Error creating Flamenco job')
self.report({'ERROR'}, 'Error creating Flamenco job: %s' % message)
self.quit()
return
job_info['missing_files'] = [str(mf) for mf in missing_sources]
json.dump(job_info, outfile, sort_keys=True, indent=4)
# BAT-pack the files to the destination directory.
job_id = job_info['_id']
outdir, outfile, missing_sources = await self.bat_pack(job_id, filepath)
if not outfile:
return
# Store the job ID in a file in the output dir, if we can.
# TODO: Make it possible to create this file first and then send it to BAT for packing.
if outdir is not None:
await self._create_jobinfo_json(
outdir, job_info, manager_id, project_id, missing_sources)
# Now that the files have been transfered, PATCH the job at the Manager
# to kick off the job compilation.
job_filepath = manager.replace_path(outfile)
self.log.info('Final file path: %s', job_filepath)
new_settings = {'filepath': job_filepath}
await self.compile_job(job_id, new_settings)
# We can now remove the local copy we made with bpy.ops.wm.save_as_mainfile().
# Strictly speaking we can already remove it after the BAT-pack, but it may come in
@@ -269,7 +436,7 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin,
from urllib.parse import urljoin
from ..blender import PILLAR_WEB_SERVER_URL
url = urljoin(PILLAR_WEB_SERVER_URL, '/flamenco/jobs/%s/redir' % job_info['_id'])
url = urljoin(PILLAR_WEB_SERVER_URL, '/flamenco/jobs/%s/redir' % job_id)
webbrowser.open_new_tab(url)
# Do a final report.
@@ -280,8 +447,75 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin,
else:
self.report({'INFO'}, 'Flamenco job created.')
if self.quit_after_submit:
silently_quit_blender()
self.quit()
async def _create_jobinfo_json(self, outdir: Path, job_info: dict,
manager_id: str, project_id: str,
missing_sources: typing.List[Path]):
from ..blender import preferences
prefs = preferences()
with open(str(outdir / 'jobinfo.json'), 'w', encoding='utf8') as outfile:
import json
# 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.
project_settings = prefs.get('project_settings', {}).get(project_id, {})
if hasattr(project_settings, 'to_dict'):
project_settings = project_settings.to_dict()
# Pop out some settings so that settings of irrelevant Managers are excluded.
flamenco_managers_settings = project_settings.pop('flamenco_managers_settings', {})
flamenco_manager_settings = flamenco_managers_settings.pop(manager_id, '-unknown-')
info = {
'_meta': {'version': 2},
'job': job_info,
'missing_files': [str(mf) for mf in missing_sources],
'exclusion_filter': (prefs.flamenco_exclude_filter or '').strip(),
'project_settings': project_settings,
'flamenco_manager_settings': flamenco_manager_settings,
}
json.dump(info, outfile, sort_keys=True, indent=4, cls=utils.JSONEncoder)
def _make_job_name(self, filepath: Path) -> str:
"""Turn a file to render into the render job name."""
job_name = filepath.name
if job_name.endswith('.blend'):
job_name = job_name[:-6]
if job_name.endswith('.flamenco'):
job_name = job_name[:-9]
return job_name
def validate_job_settings(self, context, settings: dict) -> bool:
"""Perform settings validations for the selected job type.
:returns: True if ok, False if there was an error.
"""
job_type = context.scene.flamenco_render_job_type
if job_type == 'blender-video-chunks':
# This is not really a requirement, but should catch the mistake where it was
# left at the default setting (at the moment of writing that's 1 frame per chunk).
if context.scene.flamenco_render_fchunk_size < 10:
self.report({'ERROR'}, 'Job type requires chunks of at least 10 frames.')
return False
if settings['output_file_extension'] not in {'.mkv', '.mp4', '.mov'}:
self.report({'ERROR'}, 'Job type requires rendering to Matroska or '
'MP4 files, not %r.' % settings['output_file_extension'])
return False
return True
def quit(self):
if bpy.context.window_manager.flamenco_status != 'ABORTED':
bpy.context.window_manager.flamenco_status = 'DONE'
@@ -301,6 +535,14 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin,
old_use_overwrite = render.use_overwrite
old_use_placeholder = render.use_placeholder
disable_denoiser = (context.scene.flamenco_render_job_type == 'blender-render-progressive'
and render.engine == 'CYCLES')
if disable_denoiser:
use_denoising = [layer.cycles.use_denoising
for layer in context.scene.view_layers]
else:
use_denoising = []
try:
# The file extension should be determined by the render settings, not necessarily
@@ -311,6 +553,10 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin,
render.use_overwrite = False
render.use_placeholder = False
if disable_denoiser:
for layer in context.scene.view_layers:
layer.cycles.use_denoising = False
filepath = Path(context.blend_data.filepath).with_suffix('.flamenco.blend')
self.log.info('Saving copy to temporary file %s', filepath)
bpy.ops.wm.save_as_mainfile(filepath=str(filepath),
@@ -322,16 +568,26 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin,
render.use_overwrite = old_use_overwrite
render.use_placeholder = old_use_placeholder
if disable_denoiser:
for denoise, layer in zip(use_denoising, context.scene.view_layers):
layer.cycles.use_denoising = denoise
return filepath
async def bat_pack(self, filepath: Path) -> (Path, typing.Optional[Path], typing.List[Path]):
async def bat_pack(self, job_id: str, filepath: Path) \
-> typing.Tuple[typing.Optional[Path], typing.Optional[PurePath], typing.List[Path]]:
"""BAT-packs the blendfile to the destination directory.
Returns the path of the destination blend file.
:param job_id: the job ID given to us by Flamenco Server.
:param filepath: the blend file to pack (i.e. the current blend file)
:returns: the destination directory, the destination blend file or None
if there were errors BAT-packing, and a list of missing paths.
:returns: A tuple of:
- The destination directory, or None if it does not exist on a
locally-reachable filesystem (for example when sending files to
a Shaman server).
- The destination blend file, or None if there were errors BAT-packing,
- A list of missing paths.
"""
from datetime import datetime
@@ -339,18 +595,54 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin,
prefs = preferences()
proj_abspath = bpy.path.abspath(prefs.cloud_project_local_path)
projdir = Path(proj_abspath).resolve()
exclusion_filter = (prefs.flamenco_exclude_filter or '').strip()
relative_only = prefs.flamenco_relative_only
self.log.debug('projdir: %s', projdir)
if is_shaman_url(prefs.flamenco_job_file_path):
endpoint, _ = bat_interface.parse_shaman_endpoint(prefs.flamenco_job_file_path)
self.log.info('Sending BAT pack to Shaman at %s', endpoint)
try:
outfile, missing_sources = await bat_interface.copy(
bpy.context, filepath, projdir, '/', exclusion_filter,
packer_class=bat_interface.ShamanPacker,
relative_only=relative_only,
endpoint=endpoint,
checkout_id=job_id,
manager_id=prefs.flamenco_manager.manager,
)
except bat_interface.FileTransferError as ex:
self.log.error('Could not transfer %d files, starting with %s',
len(ex.files_remaining), ex.files_remaining[0])
self.report({'ERROR'}, 'Unable to transfer %d files' % len(ex.files_remaining))
self.quit()
return None, None, []
except bat_interface.Aborted:
self.log.warning('BAT Pack was aborted')
self.report({'WARNING'}, 'Aborted Flamenco file packing/transferring')
self.quit()
return None, None, []
bpy.context.window_manager.flamenco_status = 'DONE'
outfile = PurePath('{shaman}') / outfile
return None, outfile, missing_sources
if is_file_inside_job_storage(prefs, filepath):
# The blend file is contained in the job storage path, no need to copy anything.
# Since BAT doesn't run, we also don't know whether files are missing.
return filepath.parent, filepath, []
# Create a unique directory that is still more or less identifyable.
# This should work better than a random ID.
unique_dir = '%s-%s-%s' % (datetime.now().isoformat('-').replace(':', ''),
self.db_user['username'],
filepath.stem)
outdir = Path(prefs.flamenco_job_file_path) / unique_dir
proj_abspath = bpy.path.abspath(prefs.cloud_project_local_path)
projdir = Path(proj_abspath).resolve()
exclusion_filter = (prefs.flamenco_exclude_filter or '').strip()
self.log.debug('outdir : %s', outdir)
self.log.debug('projdir: %s', projdir)
try:
outdir.mkdir(parents=True)
@@ -362,7 +654,8 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin,
try:
outfile, missing_sources = await bat_interface.copy(
bpy.context, filepath, projdir, outdir, exclusion_filter)
bpy.context, filepath, projdir, outdir, exclusion_filter,
relative_only=relative_only)
except bat_interface.FileTransferError as ex:
self.log.error('Could not transfer %d files, starting with %s',
len(ex.files_remaining), ex.files_remaining[0])
@@ -378,6 +671,20 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin,
bpy.context.window_manager.flamenco_status = 'DONE'
return outdir, outfile, missing_sources
async def compile_job(self, job_id: str, new_settings: dict) -> None:
"""Request Flamenco Server to start compiling the job."""
payload = {
'op': 'construct',
'settings': new_settings,
}
from .sdk import Job
from ..pillar import pillar_call
job = Job({'_id': job_id})
await pillar_call(job.patch, payload, caching=False)
def scene_frame_range(context) -> str:
"""Returns the frame range string for the current scene."""
@@ -464,6 +771,7 @@ class FLAMENCO_OT_abort(Operator, FlamencoPollMixin):
return {'FINISHED'}
@compatibility.convert_properties
class FLAMENCO_OT_explore_file_path(FlamencoPollMixin,
Operator):
"""Opens the Flamenco job storage path in a file explorer.
@@ -531,6 +839,19 @@ class FLAMENCO_OT_disable_output_path_override(Operator):
return {'FINISHED'}
@compatibility.convert_properties
class FLAMENCO_OT_set_recommended_sample_cap(Operator):
bl_idname = 'flamenco.set_recommended_sample_cap'
bl_label = 'Set Recommended Maximum Sample Count'
bl_description = 'Set the recommended maximum samples per render task'
sample_cap = IntProperty()
def execute(self, context):
context.scene.flamenco_render_chunk_sample_cap = self.sample_cap
return {'FINISHED'}
async def create_job(user_id: str,
project_id: str,
manager_id: str,
@@ -548,7 +869,7 @@ async def create_job(user_id: str,
from ..pillar import pillar_call
job_attrs = {
'status': 'queued',
'status': 'waiting-for-files',
'priority': priority,
'name': job_name,
'settings': job_settings,
@@ -584,12 +905,13 @@ def is_image_type(render_output_type: str) -> bool:
def _render_output_path(
local_project_path: str,
blend_filepath: Path,
flamenco_render_job_type: str,
flamenco_job_output_strip_components: int,
flamenco_job_output_path: str,
render_image_format: str,
flamenco_render_frame_range: str,
include_rel_path: bool = True,
) -> typing.Optional[PurePath]:
*,
include_rel_path: bool = True) -> typing.Optional[PurePath]:
"""Cached version of render_output_path()
This ensures that redraws of the Flamenco Render and Add-on preferences panels
@@ -621,6 +943,9 @@ def _render_output_path(
if stem.endswith('.flamenco'):
stem = stem[:-9]
if flamenco_render_job_type == 'blender-video-chunks':
return output_top / ('YYYY_MM_DD_SEQ-%s.mkv' % stem)
if include_rel_path:
rel_parts = proj_rel.parts[flamenco_job_output_strip_components:]
dir_components = output_top.joinpath(*rel_parts) / stem
@@ -658,6 +983,7 @@ def render_output_path(context, filepath: Path = None) -> typing.Optional[PurePa
return _render_output_path(
prefs.cloud_project_local_path,
filepath,
scene.flamenco_render_job_type,
prefs.flamenco_job_output_strip_components,
job_output_path,
scene.render.image_settings.file_format,
@@ -680,7 +1006,7 @@ class FLAMENCO_PT_render(bpy.types.Panel, FlamencoPollMixin):
prefs = preferences()
labeled_row = layout.split(**blender.factor(0.25), align=True)
labeled_row = layout.split(**compatibility.factor(0.25), align=True)
labeled_row.label(text='Manager:')
prop_btn_row = labeled_row.row(align=True)
@@ -698,26 +1024,57 @@ class FLAMENCO_PT_render(bpy.types.Panel, FlamencoPollMixin):
else:
prop_btn_row.label(text='Fetching available managers.')
labeled_row = layout.split(**blender.factor(0.25), align=True)
labeled_row = layout.split(**compatibility.factor(0.25), align=True)
labeled_row.label(text='Job Type:')
labeled_row.prop(context.scene, 'flamenco_render_job_type', text='')
labeled_row = layout.split(**blender.factor(0.25), align=True)
# Job-type-specific options go directly below the job type selector.
box = layout.box()
if getattr(context.scene, 'flamenco_render_job_type', None) == 'blender-render-progressive':
if bpy.app.version < (2, 80):
box.alert = True
box.label(text='Progressive rendering requires Blender 2.80 or newer.',
icon='ERROR')
# This isn't entirely fair, as Blender 2.79 could hypothetically
# be used to submit a job to farm running Blender 2.80.
return
if context.scene.render.engine != 'CYCLES':
box.alert = True
box.label(text='Progressive rendering requires Cycles', icon='ERROR')
return
box.prop(context.scene, 'flamenco_render_chunk_sample_cap')
sample_count = scene_sample_count(context.scene)
recommended_cap = sample_count // 4
split = box.split(**compatibility.factor(0.4))
split.label(text='Total Sample Count: %d' % sample_count)
props = split.operator('flamenco.set_recommended_sample_cap',
text='Recommended Max Samples per Task: %d' % recommended_cap)
props.sample_cap = recommended_cap
if any(layer.cycles.use_denoising for layer in context.scene.view_layers):
box.label(text='Progressive Rendering will disable Denoising.', icon='ERROR')
box.prop(context.scene, 'flamenco_render_fchunk_size',
text='Minimum Frames per Task')
else:
box.prop(context.scene, 'flamenco_render_fchunk_size')
labeled_row = layout.split(**compatibility.factor(0.25), align=True)
labeled_row.label(text='Frame Range:')
prop_btn_row = labeled_row.row(align=True)
prop_btn_row.prop(context.scene, 'flamenco_render_frame_range', text='')
prop_btn_row.operator('flamenco.scene_to_frame_range', text='', icon='ARROW_LEFTRIGHT')
layout.prop(context.scene, 'flamenco_render_job_priority')
layout.prop(context.scene, 'flamenco_render_fchunk_size')
layout.prop(context.scene, 'flamenco_start_paused')
if getattr(context.scene, 'flamenco_render_job_type', None) == 'blender-render-progressive':
layout.prop(context.scene, 'flamenco_render_schunk_count')
paths_layout = layout.column(align=True)
labeled_row = paths_layout.split(**blender.factor(0.25), align=True)
labeled_row = paths_layout.split(**compatibility.factor(0.25), align=True)
labeled_row.label(text='Storage:')
prop_btn_row = labeled_row.row(align=True)
prop_btn_row.label(text=prefs.flamenco_job_file_path)
@@ -725,12 +1082,18 @@ class FLAMENCO_PT_render(bpy.types.Panel, FlamencoPollMixin):
text='', icon='DISK_DRIVE')
props.path = prefs.flamenco_job_file_path
if is_file_inside_job_storage(prefs, context.blend_data.filepath):
# File is contained in the job storage path, no need to copy anything.
paths_layout.label(text='Current file already in job storage path; '
'not going to create BAT pack.')
render_output = render_output_path(context)
if render_output is None:
paths_layout.label(text='Unable to render with Flamenco, outside of project directory.')
return
labeled_row = paths_layout.split(**blender.factor(0.25), align=True)
labeled_row = paths_layout.split(**compatibility.factor(0.25), align=True)
labeled_row.label(text='Output:')
prop_btn_row = labeled_row.row(align=True)
@@ -749,16 +1112,26 @@ class FLAMENCO_PT_render(bpy.types.Panel, FlamencoPollMixin):
props.path = str(render_output.parent)
if context.scene.flamenco_do_override_output_path:
labeled_row = paths_layout.split(**blender.factor(0.25), align=True)
labeled_row = paths_layout.split(**compatibility.factor(0.25), align=True)
labeled_row.label(text='Effective Output Path:')
labeled_row.label(text=str(render_output))
self.draw_odd_size_warning(layout, context.scene.render)
# Show current status of Flamenco.
flamenco_status = context.window_manager.flamenco_status
if flamenco_status in {'IDLE', 'ABORTED', 'DONE'}:
layout.operator(FLAMENCO_OT_render.bl_idname,
text='Render on Flamenco',
icon='RENDER_ANIMATION')
if prefs.flamenco_show_quit_after_submit_button:
ui = layout.split(**compatibility.factor(0.75), align=True)
else:
ui = layout
ui.operator(FLAMENCO_OT_render.bl_idname,
text='Render on Flamenco',
icon='RENDER_ANIMATION').quit_after_submit = False
if prefs.flamenco_show_quit_after_submit_button:
ui.operator(FLAMENCO_OT_render.bl_idname,
text='Submit & Quit',
icon='RENDER_ANIMATION').quit_after_submit = True
if bpy.app.debug:
layout.operator(FLAMENCO_OT_copy_files.bl_idname)
elif flamenco_status == 'INVESTIGATING':
@@ -779,6 +1152,29 @@ class FLAMENCO_PT_render(bpy.types.Panel, FlamencoPollMixin):
elif flamenco_status != 'IDLE' and context.window_manager.flamenco_status_txt:
layout.label(text=context.window_manager.flamenco_status_txt)
def draw_odd_size_warning(self, layout, render):
render_width = render.resolution_x * render.resolution_percentage // 100
render_height = render.resolution_y * render.resolution_percentage // 100
odd_width = render_width % 2
odd_height = render_height % 2
if not odd_width and not odd_height:
return
box = layout.box()
box.alert = True
if odd_width and odd_height:
msg = 'Both X (%d) and Y (%d) resolution are' % (render_width, render_height)
elif odd_width:
msg = 'X resolution (%d) is' % render_width
else:
msg = 'Y resolution (%d) is' % render_height
box.label(text=msg + ' not divisible by 2.', icon='ERROR')
box.label(text='Any video rendered from these frames will be padded with black pixels.')
def activate():
"""Activates draw callbacks, menu items etc. for Flamenco."""
@@ -832,21 +1228,25 @@ def register():
scene = bpy.types.Scene
scene.flamenco_render_fchunk_size = IntProperty(
name='Frame Chunk Size',
description='Maximum number of frames to render per task',
name='Frames per Task',
description='Number of frames to render per task. For progressive renders this is used '
'when the sample limit is reached -- before that more frames are used',
min=1,
default=1,
)
scene.flamenco_render_schunk_count = IntProperty(
name='Number of Sample Chunks',
description='Number of Cycles samples chunks to use per frame',
min=2,
default=3,
soft_max=10,
scene.flamenco_render_chunk_sample_cap = IntProperty(
name='Maximum Samples per Task',
description='Maximum number of samples per render task; a lower number creates more '
'shorter-running tasks. Values between 1/10 and 1/4 of the total sample count '
'seem sensible',
min=1,
soft_min=5,
default=100,
soft_max=1000,
)
scene.flamenco_render_frame_range = StringProperty(
name='Frame Range',
description='Frames to render, in "printer range" notation'
description='Frames to render, in "printer range" notation',
)
scene.flamenco_render_job_type = EnumProperty(
name='Job Type',
@@ -854,6 +1254,8 @@ def register():
('blender-render', 'Simple Render', 'Simple frame-by-frame render'),
('blender-render-progressive', 'Progressive Render',
'Each frame is rendered multiple times with different Cycles sample chunks, then combined'),
('blender-video-chunks', 'Video Chunks',
'Render each frame chunk to a video file, then concateate those video files')
]
)
@@ -866,7 +1268,7 @@ def register():
scene.flamenco_render_job_priority = IntProperty(
name='Job Priority',
min=0,
min=1,
default=50,
max=100,
description='Higher numbers mean higher priority'
@@ -926,7 +1328,7 @@ def unregister():
log.warning('Unable to unregister class %r, probably already unregistered', cls)
for name in ('flamenco_render_fchunk_size',
'flamenco_render_schunk_count',
'flamenco_render_chunk_sample_cap',
'flamenco_render_frame_range',
'flamenco_render_job_type',
'flamenco_start_paused',

View File

@@ -2,14 +2,15 @@
import asyncio
import logging
import pathlib
import re
import threading
import typing
import pathlib
import urllib.parse
import bpy
from blender_asset_tracer import pack
from blender_asset_tracer.pack import progress, transfer
from blender_asset_tracer.pack import progress, transfer, shaman
log = logging.getLogger(__name__)
@@ -19,6 +20,7 @@ _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):
@@ -63,8 +65,8 @@ class BatProgress(progress.Callback):
else:
self._txt('Pack of %s done' % output_blendfile.name)
def pack_aborted(self):
self._txt('Aborted')
def pack_aborted(self, reason: str):
self._txt('Aborted: %s' % reason)
self._status('ABORTED')
def trace_blendfile(self, filename: pathlib.Path) -> None:
@@ -93,11 +95,46 @@ class BatProgress(progress.Callback):
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: pathlib.Path,
exclusion_filter: str) -> typing.Tuple[pathlib.Path, typing.Set[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.
@@ -106,13 +143,18 @@ async def copy(context,
global _running_packer
loop = asyncio.get_event_loop()
wm = bpy.context.window_manager
with pack.Packer(base_blendfile, project, target) as packer:
packer = packer_class(base_blendfile, project, target,
compress=True, relative_only=relative_only, **packer_args)
with packer:
with _packer_lock:
if exclusion_filter:
packer.exclude(*exclusion_filter.split())
# 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

View File

@@ -1,5 +1,6 @@
import functools
import pathlib
import typing
from pillarsdk.resource import List, Find, Create
@@ -9,8 +10,28 @@ class Manager(List, Find):
path = 'flamenco/managers'
PurePlatformPath = pathlib.PurePath
@functools.lru_cache()
def _sorted_path_replacements(self) -> list:
@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:
@@ -18,22 +39,45 @@ class Manager(List, Find):
items = self.path_replacement.to_dict().items()
def by_length(item):
return -len(item[0]), item[0]
this_platform = platform.system().lower()
return [(varname, platform_replacements[this_platform])
for varname, platform_replacements in sorted(items, key=by_length)
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._sorted_path_replacements():
for varname, path in self._path_replacements():
replacement = self.PurePlatformPath(path)
try:
relpath = some_path.relative_to(replacement)
@@ -52,3 +96,14 @@ class Job(List, Find, Create):
"""
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

@@ -25,7 +25,7 @@ import bpy
import pillarsdk
from pillarsdk import exceptions as sdk_exceptions
from .pillar import pillar_call
from . import async_loop, pillar, home_project, blender
from . import async_loop, compatibility, pillar, home_project, blender
REQUIRES_ROLES_FOR_IMAGE_SHARING = {'subscriber', 'demo'}
IMAGE_SHARING_GROUP_NODE_NAME = 'Image sharing'
@@ -53,6 +53,7 @@ async def find_image_sharing_group_id(home_project_id, user_id):
return share_group['_id']
@compatibility.convert_properties
class PILLAR_OT_image_share(pillar.PillarOperatorMixin,
async_loop.AsyncModalOperatorMixin,
bpy.types.Operator):

View File

@@ -26,7 +26,8 @@ from contextlib import closing, contextmanager
import urllib.parse
import pathlib
import requests
import requests.adapters
import requests.packages.urllib3.util.retry
import requests.structures
import pillarsdk
import pillarsdk.exceptions
@@ -42,7 +43,16 @@ RFC1123_DATE_FORMAT = '%a, %d %b %Y %H:%M:%S GMT'
_pillar_api = {} # will become a mapping from bool (cached/non-cached) to pillarsdk.Api objects.
log = logging.getLogger(__name__)
_retries = requests.packages.urllib3.util.retry.Retry(
total=10,
backoff_factor=0.05,
)
_http_adapter = requests.adapters.HTTPAdapter(max_retries=_retries)
uncached_session = requests.session()
uncached_session.mount('https://', _http_adapter)
uncached_session.mount('http://', _http_adapter)
_testing_blender_id_profile = None # Just for testing, overrides what is returned by blender_id_profile.
_downloaded_urls = set() # URLs we've downloaded this Blender session.
@@ -96,18 +106,18 @@ class CloudPath(pathlib.PurePosixPath):
def project_uuid(self) -> str:
assert self.parts[0] == '/'
if len(self.parts) <= 1:
return None
return ''
return self.parts[1]
@property
def node_uuids(self) -> list:
def node_uuids(self) -> tuple:
assert self.parts[0] == '/'
return self.parts[2:]
@property
def node_uuid(self) -> str:
if len(self.parts) <= 2:
return None
return ''
return self.parts[-1]

View File

@@ -2,6 +2,7 @@
import contextlib
import logging
import typing
# Names of BlenderCloudPreferences properties that are both project-specific
# and simple enough to store directly in a dict.
@@ -9,19 +10,53 @@ 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 = False
project_settings_loading = 0 # counter, if > 0 then we're loading stuff.
@contextlib.contextmanager
def mark_as_loading():
"""Sets project_settings_loading=True while the context is active."""
"""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 = True
project_settings_loading += 1
try:
yield
finally:
project_settings_loading = False
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):
@@ -66,9 +101,7 @@ def handle_project_update(_=None, _2=None):
log.debug('loading project-specific settings:\n%s', pformat(ps.to_dict()))
# Restore simple properties.
for name in PROJECT_SPECIFIC_SIMPLE_PROPS:
if name in ps and hasattr(prefs, name):
setattr(prefs, name, ps[name])
update_preferences(prefs, PROJECT_SPECIFIC_SIMPLE_PROPS, ps)
# Restore Flamenco settings.
prefs.flamenco_manager.available_managers = ps.get('flamenco_available_managers', [])
@@ -76,22 +109,12 @@ def handle_project_update(_=None, _2=None):
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)
else:
# Load per-project, per-manager settings for the current Manager.
try:
pppm = ps['flamenco_managers_settings'][flamenco_manager_id]
except KeyError:
# No settings for this manager, so nothing to do.
pass
else:
prefs.flamenco_job_file_path = pppm['file_path']
prefs.flamenco_job_output_path = pppm['output_path']
prefs.flamenco_job_output_strip_components = pppm['output_strip_components']
elif prefs.flamenco_manager.available_managers:
prefs.flamenco_manager.manager = prefs.flamenco_manager.available_managers[0]
prefs.flamenco_manager.manager = prefs.flamenco_manager.available_managers[0]['_id']
def store(_=None, _2=None):
@@ -123,9 +146,8 @@ def store(_=None, _2=None):
# Store per-project, per-manager settings for the current Manager.
pppm = ps.get('flamenco_managers_settings', {})
pppm[prefs.flamenco_manager.manager] = {
'file_path': prefs.flamenco_job_file_path,
'output_path': prefs.flamenco_job_output_path,
'output_strip_components': prefs.flamenco_job_output_strip_components}
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.

View File

@@ -25,6 +25,7 @@ import functools
import logging
import pathlib
import tempfile
import typing
import shutil
import bpy
@@ -34,7 +35,7 @@ import asyncio
import pillarsdk
from pillarsdk import exceptions as sdk_exceptions
from .pillar import pillar_call
from . import async_loop, pillar, cache, blendfile, home_project
from . import async_loop, blender, compatibility, pillar, cache, blendfile, home_project
SETTINGS_FILES_TO_UPLOAD = ['userpref.blend', 'startup.blend']
@@ -100,7 +101,7 @@ async def find_sync_group_id(home_project_id: str,
user_id: str,
blender_version: str,
*,
may_create=True) -> str:
may_create=True) -> typing.Tuple[str, str]:
"""Finds the group node in which to store sync assets.
If the group node doesn't exist and may_create=True, it creates it.
@@ -122,7 +123,7 @@ async def find_sync_group_id(home_project_id: str,
if not may_create and sync_group is None:
log.info("Sync folder doesn't exist, and not creating it either.")
return None, None
return '', ''
# Find/create the sub-group for the requested Blender version
try:
@@ -144,7 +145,7 @@ async def find_sync_group_id(home_project_id: str,
if not may_create and sub_sync_group is None:
log.info("Sync folder for Blender version %s doesn't exist, "
"and not creating it either.", blender_version)
return sync_group['_id'], None
return sync_group['_id'], ''
return sync_group['_id'], sub_sync_group['_id']
@@ -195,6 +196,7 @@ async def available_blender_versions(home_project_id: str, user_id: str) -> list
# noinspection PyAttributeOutsideInit
@compatibility.convert_properties
class PILLAR_OT_sync(pillar.PillarOperatorMixin,
async_loop.AsyncModalOperatorMixin,
bpy.types.Operator):
@@ -203,9 +205,9 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
bl_description = 'Synchronises Blender settings with Blender Cloud'
log = logging.getLogger('bpy.ops.%s' % bl_idname)
home_project_id = None
sync_group_id = None # top-level sync group node ID
sync_group_versioned_id = None # sync group node ID for the given Blender version.
home_project_id = ''
sync_group_id = '' # top-level sync group node ID
sync_group_versioned_id = '' # sync group node ID for the given Blender version.
action = bpy.props.EnumProperty(
items=[
@@ -387,12 +389,12 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
"""Loads files from the Pillar server."""
# If the sync group node doesn't exist, offer a list of groups that do.
if self.sync_group_id is None:
if not self.sync_group_id:
self.bss_report({'ERROR'},
'There are no synced Blender settings in your Blender Cloud.')
return
if self.sync_group_versioned_id is None:
if not self.sync_group_versioned_id:
self.bss_report({'ERROR'}, 'Therre are no synced Blender settings for version %s' %
self.blender_version)
return
@@ -415,11 +417,20 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
bss = bpy.context.window_manager.blender_sync_status
bss.available_blender_versions = versions
if 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.
self.bss_report({'INFO'}, '')
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'))
@@ -476,13 +487,13 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
self.log.info('Overriding machine-local settings in %s', file_path)
# Remember some settings that should not be overwritten from the Cloud.
up = bpy.context.user_preferences
prefs = blender.ctx_preferences()
remembered = {}
for rna_key, python_key in LOCAL_SETTINGS_RNA:
assert '.' in python_key, 'Sorry, this code assumes there is a dot in the Python key'
try:
value = up.path_resolve(python_key)
value = prefs.path_resolve(python_key)
except ValueError:
# Setting doesn't exist. This can happen, for example Cycles
# settings on a build that doesn't have Cycles enabled.
@@ -491,7 +502,7 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
# Map enums from strings (in Python) to ints (in DNA).
dot_index = python_key.rindex('.')
parent_key, prop_key = python_key[:dot_index], python_key[dot_index + 1:]
parent = up.path_resolve(parent_key)
parent = prefs.path_resolve(parent_key)
prop = parent.bl_rna.properties[prop_key]
if prop.type == 'ENUM':
log.debug('Rewriting %s from %r to %r',

View File

@@ -18,249 +18,35 @@
import asyncio
import logging
import threading
import os
import threading
import typing
import bpy
import bgl
import blf
import pillarsdk
from . import async_loop, pillar, cache, blender, utils
from .. import async_loop, compatibility, pillar, cache, blender, utils
from . import menu_item as menu_item_mod # so that we can have menu items called 'menu_item'
from . import nodes
if bpy.app.version < (2, 80):
from . import draw_27 as draw
else:
from . import draw
REQUIRED_ROLES_FOR_TEXTURE_BROWSER = {'subscriber', 'demo'}
MOUSE_SCROLL_PIXELS_PER_TICK = 50
ICON_WIDTH = 128
ICON_HEIGHT = 128
TARGET_ITEM_WIDTH = 400
TARGET_ITEM_HEIGHT = 128
ITEM_MARGIN_X = 5
ITEM_MARGIN_Y = 5
ITEM_PADDING_X = 5
library_path = '/tmp'
library_icons_path = os.path.join(os.path.dirname(__file__), "icons")
log = logging.getLogger(__name__)
class SpecialFolderNode(pillarsdk.Node):
NODE_TYPE = 'SPECIAL'
class UpNode(SpecialFolderNode):
NODE_TYPE = 'UP'
def __init__(self):
super().__init__()
self['_id'] = 'UP'
self['node_type'] = self.NODE_TYPE
class ProjectNode(SpecialFolderNode):
NODE_TYPE = 'PROJECT'
def __init__(self, project):
super().__init__()
assert isinstance(project, pillarsdk.Project), 'wrong type for project: %r' % type(project)
self.merge(project.to_dict())
self['node_type'] = self.NODE_TYPE
class MenuItem:
"""GUI menu item for the 3D View GUI."""
icon_margin_x = 4
icon_margin_y = 4
text_margin_x = 6
text_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', UpNode.NODE_TYPE, ProjectNode.NODE_TYPE}
SUPPORTED_NODE_TYPES = {'texture', 'hdri'}.union(FOLDER_NODE_TYPES)
def __init__(self, node, file_desc, thumb_path: str, label_text):
self.log = logging.getLogger('%s.MenuItem' % __name__)
if node['node_type'] not in self.SUPPORTED_NODE_TYPES:
self.log.info('Invalid node type in node: %s', node)
raise TypeError('Node of type %r not supported; supported are %r.' % (
node['node_type'], self.SUPPORTED_NODE_TYPES))
assert isinstance(node, pillarsdk.Node), 'wrong type for node: %r' % type(node)
assert isinstance(node['_id'], str), 'wrong type for node["_id"]: %r' % type(node['_id'])
self.node = node # pillarsdk.Node, contains 'node_type' key to indicate type
self.file_desc = file_desc # pillarsdk.File object, or None if a 'folder' node.
self.label_text = label_text
self.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:
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
if texture:
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 ---------#
if 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)
if texture:
texture.gl_free()
# draw some text
font_id = 0
text_dpi = bpy.context.user_preferences.system.dpi
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
blf.position(font_id, text_x, text_y, 0)
blf.size(font_id, self.text_size, text_dpi)
blf.draw(font_id, self.label_text)
# draw the small text
bgl.glColor4f(1.0, 1.0, 1.0, 0.5)
blf.size(font_id, self.text_size_small, text_dpi)
blf.position(font_id, text_x, self.y + 0.5 * self.text_size_small, 0)
blf.draw(font_id, self.small_text)
def hits(self, mouse_x: int, mouse_y: int) -> bool:
return self.x < mouse_x < self.x + self.width and self.y < mouse_y < self.y + self.height
class BlenderCloudBrowser(pillar.PillarOperatorMixin,
async_loop.AsyncModalOperatorMixin,
bpy.types.Operator):
@@ -273,17 +59,17 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
project_name = ''
# This contains a stack of Node objects that lead up to the currently browsed node.
path_stack = []
path_stack = [] # type: typing.List[pillarsdk.Node]
# This contains a stack of MenuItem objects that lead up to the currently browsed node.
menu_item_stack = []
menu_item_stack = [] # type: typing.List[menu_item_mod.MenuItem]
timer = None
log = logging.getLogger('%s.BlenderCloudBrowser' % __name__)
_menu_item_lock = threading.Lock()
current_display_content = [] # list of MenuItems currently displayed
loaded_images = set()
current_display_content = [] # type: typing.List[menu_item_mod.MenuItem]
loaded_images = set() # type: typing.Set[str]
thumbnails_cache = ''
maximized_area = False
@@ -422,7 +208,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
bpy.context.window.cursor_set('HAND')
def descend_node(self, menu_item: MenuItem):
def descend_node(self, menu_item: menu_item_mod.MenuItem):
"""Descends the node hierarchy by visiting this menu item's node.
Also keeps track of the current node, so that we know where the "up" button should go.
@@ -431,7 +217,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
node = menu_item.node
assert isinstance(node, pillarsdk.Node), 'Wrong type %s' % node
if isinstance(node, UpNode):
if isinstance(node, nodes.UpNode):
# Going up.
self.log.debug('Going up to %r', self.current_path)
self.current_path = self.current_path.parent
@@ -443,7 +229,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self.project_name = ''
else:
# Going down, keep track of where we were
if isinstance(node, ProjectNode):
if isinstance(node, nodes.ProjectNode):
self.project_name = node['name']
self.current_path /= node['_id']
@@ -486,13 +272,14 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self.loaded_images.clear()
self.current_display_content.clear()
def add_menu_item(self, *args) -> MenuItem:
menu_item = MenuItem(*args)
def add_menu_item(self, *args) -> menu_item_mod.MenuItem:
menu_item = menu_item_mod.MenuItem(*args)
# Just make this thread-safe to be on the safe side.
with self._menu_item_lock:
self.current_display_content.append(menu_item)
self.loaded_images.add(menu_item.icon.filepath_raw)
if menu_item.icon is not None:
self.loaded_images.add(menu_item.icon.filepath_raw)
self.sort_menu()
@@ -520,7 +307,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
return
with self._menu_item_lock:
self.current_display_content.sort(key=MenuItem.sort_key)
self.current_display_content.sort(key=menu_item_mod.MenuItem.sort_key)
async def async_download_previews(self):
self._state = 'BROWSING'
@@ -550,17 +337,17 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self.log.debug('No node UUID and no project UUID, listing available projects')
children = await pillar.get_texture_projects()
for proj_dict in children:
self.add_menu_item(ProjectNode(proj_dict), None, 'FOLDER', proj_dict['name'])
self.add_menu_item(nodes.ProjectNode(proj_dict), None, 'FOLDER', proj_dict['name'])
return
# Make sure we can go up again.
self.add_menu_item(UpNode(), None, 'FOLDER', '.. up ..')
self.add_menu_item(nodes.UpNode(), None, 'FOLDER', '.. up ..')
# Download all child nodes
self.log.debug('Iterating over child nodes of %r', self.current_path)
for child in children:
# print(' - %(_id)s = %(name)s' % child)
if child['node_type'] not in MenuItem.SUPPORTED_NODE_TYPES:
if child['node_type'] not in menu_item_mod.MenuItem.SUPPORTED_NODE_TYPES:
self.log.debug('Skipping node of type %r', child['node_type'])
continue
self.add_menu_item(child, None, 'FOLDER', child['name'])
@@ -610,12 +397,9 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
drawer(context)
# For debugging: draw the state
font_id = 0
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
blf.size(font_id, 20, 72)
blf.position(font_id, 5, 5, 0)
blf.draw(font_id, '%s %s' % (self._state, self.project_name))
bgl.glDisable(bgl.GL_BLEND)
draw.text((5, 5),
'%s %s' % (self._state, self.project_name),
rgba=(1.0, 1.0, 1.0, 1.0), fsize=12)
@staticmethod
def _window_region(context):
@@ -626,6 +410,12 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
def _draw_browser(self, context):
"""OpenGL drawing code for the BROWSING state."""
from . import draw
if not self.current_display_content:
self._draw_text_on_colour(context, "Communicating with Blender Cloud",
(0.0, 0.0, 0.0, 0.6))
return
window_region = self._window_region(context)
content_width = window_region.width - ITEM_MARGIN_X * 2
@@ -643,46 +433,33 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
block_height = item_height + ITEM_MARGIN_Y
bgl.glEnable(bgl.GL_BLEND)
bgl.glColor4f(0.0, 0.0, 0.0, 0.6)
bgl.glRectf(0, 0, window_region.width, window_region.height)
draw.aabox((0, 0), (window_region.width, window_region.height),
(0.0, 0.0, 0.0, 0.6))
if self.current_display_content:
bottom_y = float('inf')
bottom_y = float('inf')
# The -1 / +2 are for extra rows that are drawn only half at the top/bottom.
first_item_idx = max(0, int(-self.scroll_offset // block_height - 1) * col_count)
items_per_page = int(content_height // item_height + 2) * col_count
last_item_idx = first_item_idx + items_per_page
# The -1 / +2 are for extra rows that are drawn only half at the top/bottom.
first_item_idx = max(0, int(-self.scroll_offset // block_height - 1) * col_count)
items_per_page = int(content_height // item_height + 2) * col_count
last_item_idx = first_item_idx + items_per_page
for item_idx, item in enumerate(self.current_display_content):
x = content_x + (item_idx % col_count) * block_width
y = content_y - (item_idx // col_count) * block_height - self.scroll_offset
for item_idx, item in enumerate(self.current_display_content):
x = content_x + (item_idx % col_count) * block_width
y = content_y - (item_idx // col_count) * block_height - self.scroll_offset
item.update_placement(x, y, item_width, item_height)
item.update_placement(x, y, item_width, item_height)
if first_item_idx <= item_idx < last_item_idx:
# Only draw if the item is actually on screen.
item.draw(highlighted=item.hits(self.mouse_x, self.mouse_y))
if first_item_idx <= item_idx < last_item_idx:
# Only draw if the item is actually on screen.
item.draw(highlighted=item.hits(self.mouse_x, self.mouse_y))
bottom_y = min(y, bottom_y)
self.scroll_offset_space_left = window_region.height - bottom_y
self.scroll_offset_max = (self.scroll_offset -
self.scroll_offset_space_left +
0.25 * block_height)
else:
font_id = 0
text = "Communicating with Blender Cloud"
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
blf.size(font_id, 20, 72)
text_width, text_height = blf.dimensions(font_id, text)
blf.position(font_id,
content_x + content_width * 0.5 - text_width * 0.5,
content_y - content_height * 0.3 + text_height * 0.5, 0)
blf.draw(font_id, text)
bottom_y = min(y, bottom_y)
self.scroll_offset_space_left = window_region.height - bottom_y
self.scroll_offset_max = (self.scroll_offset -
self.scroll_offset_space_left +
0.25 * block_height)
bgl.glDisable(bgl.GL_BLEND)
# bgl.glColor4f(0.0, 0.0, 0.0, 1.0)
def _draw_downloading(self, context):
"""OpenGL drawing code for the DOWNLOADING_TEXTURE state."""
@@ -705,21 +482,15 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
'Initializing',
(0.0, 0.0, 0.2, 0.6))
def _draw_text_on_colour(self, context, text, bgcolour):
def _draw_text_on_colour(self, context, text: str, bgcolour):
content_height, content_width = self._window_size(context)
bgl.glEnable(bgl.GL_BLEND)
bgl.glColor4f(*bgcolour)
bgl.glRectf(0, 0, content_width, content_height)
font_id = 0
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
blf.size(font_id, 20, 72)
text_width, text_height = blf.dimensions(font_id, text)
draw.aabox((0, 0), (content_width, content_height), bgcolour)
draw.text((content_width * 0.5, content_height * 0.7),
text, fsize=20, align='C')
blf.position(font_id,
content_width * 0.5 - text_width * 0.5,
content_height * 0.7 + text_height * 0.5, 0)
blf.draw(font_id, text)
bgl.glDisable(bgl.GL_BLEND)
def _window_size(self, context):
@@ -736,33 +507,21 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
content_height, content_width = self._window_size(context)
bgl.glEnable(bgl.GL_BLEND)
bgl.glColor4f(0.2, 0.0, 0.0, 0.6)
bgl.glRectf(0, 0, content_width, content_height)
draw.aabox((0, 0), (content_width, content_height), (0.2, 0.0, 0.0, 0.6))
font_id = 0
ex = self.async_task.exception()
if isinstance(ex, pillar.UserNotLoggedInError):
ex_msg = 'You are not logged in on Blender ID. Please log in at User Preferences, ' \
'System, Blender ID.'
'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)
lines = textwrap.wrap(text, width=100)
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
blf.size(font_id, 20, 72)
_, text_height = blf.dimensions(font_id, 'yhBp')
draw.text((content_width * 0.1, content_height * 0.9), lines, fsize=16)
def position(line_nr):
blf.position(font_id,
content_width * 0.1,
content_height * 0.8 - line_nr * text_height, 0)
for line_idx, line in enumerate(lines):
position(line_idx)
blf.draw(font_id, line)
bgl.glDisable(bgl.GL_BLEND)
def _draw_subscribe(self, context):
@@ -775,7 +534,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
'Click to renew your Blender Cloud subscription',
(0.0, 0.0, 0.2, 0.6))
def get_clicked(self) -> MenuItem:
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):
@@ -783,7 +542,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
return None
def handle_item_selection(self, context, item: MenuItem):
def handle_item_selection(self, context, item: menu_item_mod.MenuItem):
"""Called when the user clicks on a menu item that doesn't represent a folder."""
from pillarsdk.utils import sanitize_filename
@@ -889,6 +648,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self.scroll_offset_target = self.scroll_offset = 0
@compatibility.convert_properties
class PILLAR_OT_switch_hdri(pillar.PillarOperatorMixin,
async_loop.AsyncModalOperatorMixin,
bpy.types.Operator):
@@ -935,13 +695,11 @@ class PILLAR_OT_switch_hdri(pillar.PillarOperatorMixin,
self._state = 'QUIT'
async def download_and_replace(self, context):
from .pillar import sanitize_filename
self._state = 'DOWNLOADING_TEXTURE'
current_image = bpy.data.images[self.image_name]
node = current_image['bcloud_node']
filename = '%s.taken_from_file' % sanitize_filename(node['name'])
filename = '%s.taken_from_file' % pillar.sanitize_filename(node['name'])
local_path = os.path.dirname(bpy.path.abspath(current_image.filepath))
top_texture_directory = bpy.path.abspath(context.scene.local_texture_dir)
@@ -951,21 +709,27 @@ class PILLAR_OT_switch_hdri(pillar.PillarOperatorMixin,
resolution = next(file_ref['resolution'] for file_ref in node['properties']['files']
if file_ref['file'] == file_uuid)
self.log.info('Downloading file %r-%s to %s', file_uuid, resolution, local_path)
self.log.debug('Metadata will be stored at %s', meta_path)
my_log = self.log
my_log.info('Downloading file %r-%s to %s', file_uuid, resolution, local_path)
my_log.debug('Metadata will be stored at %s', meta_path)
def file_loading(file_path, file_desc, map_type):
self.log.info('Texture downloading to %s (%s)',
file_path, utils.sizeof_fmt(file_desc['length']))
my_log.info('Texture downloading to %s (%s)',
file_path, utils.sizeof_fmt(file_desc['length']))
async def file_loaded(file_path, file_desc, map_type):
if context.scene.local_texture_dir.startswith('//'):
file_path = bpy.path.relpath(file_path)
self.log.info('Texture downloaded to %s', file_path)
my_log.info('Texture downloaded to %s', file_path)
current_image['bcloud_file_uuid'] = file_uuid
current_image.filepath = file_path # This automatically reloads the image from disk.
# This forces users of the image to update.
for datablocks in bpy.data.user_map({current_image}).values():
for datablock in datablocks:
datablock.update_tag()
await pillar.download_file_by_uuid(file_uuid,
local_path,
meta_path,
@@ -1013,7 +777,7 @@ def _hdri_download_panel(self, current_image):
current_image.name)
return
row = self.layout.row(align=True).split(**blender.factor(0.3))
row = self.layout.row(align=True).split(**compatibility.factor(0.3))
row.label(text='HDRi', icon_value=blender.icon('CLOUD'))
row.prop(current_image, 'hdri_variation', text='')

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

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

View File

@@ -16,7 +16,9 @@
#
# ##### END GPL LICENSE BLOCK #####
import json
import pathlib
import typing
def sizeof_fmt(num: int, suffix='B') -> str:
@@ -28,12 +30,12 @@ def sizeof_fmt(num: int, suffix='B') -> str:
for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']:
if abs(num) < 1024:
return '%.1f %s%s' % (num, unit, suffix)
num /= 1024
num //= 1024
return '%.1f Yi%s' % (num, suffix)
def find_in_path(path: pathlib.Path, filename: str) -> pathlib.Path:
def find_in_path(path: pathlib.Path, filename: str) -> typing.Optional[pathlib.Path]:
"""Performs a breadth-first search for the filename.
Returns the path that contains the file, or None if not found.
@@ -92,7 +94,10 @@ def pyside_cache(propname):
result = wrapped(self, context)
return result
finally:
rna_type, rna_info = getattr(self.bl_rna, propname)
try:
rna_type, rna_info = self.bl_rna.__annotations__[propname]
except AttributeError:
rna_type, rna_info = getattr(self.bl_rna, propname)
rna_info['_cached_result'] = result
return wrapper
return decorator
@@ -102,3 +107,12 @@ 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)

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

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

View File

@@ -1,9 +1,9 @@
# Primary requirements:
-e git+https://github.com/sybrenstuvel/cachecontrol.git@sybren-filecache-delete-crash-fix#egg=CacheControl
lockfile==0.12.2
pillarsdk==1.7.0
pillarsdk==1.8.0
wheel==0.29.0
blender-asset-tracer>=0.4
blender-asset-tracer==1.2.1
# Secondary requirements:
asn1crypto==0.24.0

View File

@@ -28,7 +28,7 @@ import zipfile
from distutils import log
from distutils.core import Command
from distutils.command.bdist import bdist
from distutils.command.install import install
from distutils.command.install import install, INSTALL_SCHEMES
from distutils.command.install_egg_info import install_egg_info
from setuptools import setup, find_packages
@@ -123,8 +123,8 @@ class BuildWheels(Command):
"""Downloads a wheel from PyPI and saves it in self.wheels_path."""
subprocess.check_call([
'pip', 'download',
'--no-deps',
sys.executable, '-m', 'pip',
'download', '--no-deps',
'--dest', str(self.wheels_path),
requirement[0]
])
@@ -165,6 +165,14 @@ class BlenderAddonBdist(bdist):
super().initialize_options()
self.formats = ['zip']
self.plat_name = 'addon' # use this instead of 'linux-x86_64' or similar.
self.fix_local_prefix()
def fix_local_prefix(self):
"""Place data files in blender_cloud instead of local/blender_cloud."""
for key in INSTALL_SCHEMES:
if 'data' not in INSTALL_SCHEMES[key]:
continue
INSTALL_SCHEMES[key]['data'] = '$base'
def run(self):
self.run_command('wheels')
@@ -228,12 +236,16 @@ setup(
'wheels': BuildWheels},
name='blender_cloud',
description='The Blender Cloud addon allows browsing the Blender Cloud from Blender.',
version='1.9.2',
version='1.16',
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/*'))],
data_files=[
('blender_cloud', ['README.md', 'README-flamenco.md', 'CHANGELOG.md']),
('blender_cloud/icons', glob.glob('blender_cloud/icons/*')),
('blender_cloud/texture_browser/icons',
glob.glob('blender_cloud/texture_browser/icons/*'))
],
scripts=[],
url='https://developer.blender.org/diffusion/BCA/',
license='GNU General Public License v2 or later (GPLv2+)',

View File

@@ -17,3 +17,6 @@ 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