diff --git a/docs/getting_started/img/quickstart_cancel_save_session_data.png b/docs/getting_started/img/quickstart_cancel_save_session_data.png new file mode 100644 index 0000000..81e5793 Binary files /dev/null and b/docs/getting_started/img/quickstart_cancel_save_session_data.png differ diff --git a/docs/getting_started/img/quickstart_import_session_data.png b/docs/getting_started/img/quickstart_import_session_data.png new file mode 100644 index 0000000..83dadfd Binary files /dev/null and b/docs/getting_started/img/quickstart_import_session_data.png differ diff --git a/docs/getting_started/img/quickstart_save_session_data.png b/docs/getting_started/img/quickstart_save_session_data.png new file mode 100644 index 0000000..08841b1 Binary files /dev/null and b/docs/getting_started/img/quickstart_save_session_data.png differ diff --git a/docs/getting_started/img/quickstart_save_session_data_cancel.png b/docs/getting_started/img/quickstart_save_session_data_cancel.png new file mode 100644 index 0000000..81e5793 Binary files /dev/null and b/docs/getting_started/img/quickstart_save_session_data_cancel.png differ diff --git a/docs/getting_started/img/quickstart_save_session_data_dialog.png b/docs/getting_started/img/quickstart_save_session_data_dialog.png new file mode 100644 index 0000000..941fe80 Binary files /dev/null and b/docs/getting_started/img/quickstart_save_session_data_dialog.png differ diff --git a/docs/getting_started/quickstart.rst b/docs/getting_started/quickstart.rst index 4e25ac8..d81eff1 100644 --- a/docs/getting_started/quickstart.rst +++ b/docs/getting_started/quickstart.rst @@ -292,7 +292,7 @@ a connected user or be under :ref:`common-right<**COMMON**>` rights. The Repository panel (see image below) allows you to monitor, change datablock states and rights manually. -.. figure:: img/quickstart_properties.png +.. figure:: img/quickstart_save_session_data.png :align: center Repository panel @@ -319,6 +319,40 @@ Here is a quick list of available actions: | .. image:: img/quickstart_remove.png | **Delete** | Remove the data-block from network replication | +---------------------------------------+-------------------+------------------------------------------------------------------------------------+ +Save session data +----------------- + +.. danger:: + This is an experimental feature, until the stable release it is highly recommended to use regular .blend save. + +The save session data allows you to create a backup of the session data. + +When you hit the **save session data** button, the following popup dialog will appear. +It allows you to choose the destination folder and if you want to run an auto-save. + +.. figure:: img/quickstart_save_session_data_dialog.png + :align: center + + Save session data dialog. + +If you enabled the auto-save option, you can cancel it from the **Cancel auto-save** button. + +.. figure:: img/quickstart_save_session_data_cancel.png + :align: center + + Cancel session autosave. + + +To import session data backups, use the following **Multiuser session snapshot** import dialog + +.. figure:: img/quickstart_import_session_data.png + :align: center + + Import session data dialog. + +.. note:: + It is not yet possible to start a session directly from a backup. + .. _advanced: Advanced settings diff --git a/multi_user/__init__.py b/multi_user/__init__.py index 0d5d9a2..783037b 100644 --- a/multi_user/__init__.py +++ b/multi_user/__init__.py @@ -89,6 +89,8 @@ def register(): type=preferences.SessionUser ) bpy.types.WindowManager.user_index = bpy.props.IntProperty() + bpy.types.TOPBAR_MT_file_import.append(operators.menu_func_import) + def unregister(): from . import presence @@ -97,6 +99,8 @@ def unregister(): from . import preferences from . import addon_updater_ops + bpy.types.TOPBAR_MT_file_import.remove(operators.menu_func_import) + presence.unregister() addon_updater_ops.unregister() ui.unregister() diff --git a/multi_user/operators.py b/multi_user/operators.py index 5dbbd7d..24942e2 100644 --- a/multi_user/operators.py +++ b/multi_user/operators.py @@ -17,6 +17,8 @@ import asyncio +import copy +import gzip import logging import os import queue @@ -25,27 +27,35 @@ import shutil import string import sys import time +from datetime import datetime from operator import itemgetter from pathlib import Path from queue import Queue +from time import gmtime, strftime + +try: + import _pickle as pickle +except ImportError: + import pickle import bpy import mathutils from bpy.app.handlers import persistent -from replication.constants import (FETCHED, RP_COMMON, STATE_ACTIVE, +from bpy_extras.io_utils import ExportHelper, ImportHelper +from replication.constants import (COMMITED, FETCHED, RP_COMMON, STATE_ACTIVE, STATE_INITIAL, STATE_SYNCING, UP) from replication.data import ReplicatedDataFactory from replication.exception import NonAuthorizedOperationError from replication.interface import session -from . import bl_types, delayable, environment, ui, utils +from . import bl_types, environment, timers, ui, utils from .presence import SessionStatusWidget, renderer, view3d_find +from .timers import registry background_execution_queue = Queue() deleyables = [] stop_modal_executor = False - def session_callback(name): """ Session callback wrapper @@ -193,8 +203,8 @@ class SessionStartOperator(bpy.types.Operator): if settings.update_method == 'DEFAULT': if type_local_config.bl_delay_apply > 0: deleyables.append( - delayable.ApplyTimer( - timout=type_local_config.bl_delay_apply, + timers.ApplyTimer( + timeout=type_local_config.bl_delay_apply, target_type=type_module_class)) if bpy.app.version[1] >= 91: @@ -208,7 +218,7 @@ class SessionStartOperator(bpy.types.Operator): external_update_handling=use_extern_update) if settings.update_method == 'DEPSGRAPH': - deleyables.append(delayable.ApplyTimer( + deleyables.append(timers.ApplyTimer( settings.depsgraph_update_rate/1000)) # Host a session @@ -259,12 +269,12 @@ class SessionStartOperator(bpy.types.Operator): logging.error(str(e)) # Background client updates service - deleyables.append(delayable.ClientUpdate()) - deleyables.append(delayable.DynamicRightSelectTimer()) + deleyables.append(timers.ClientUpdate()) + deleyables.append(timers.DynamicRightSelectTimer()) - session_update = delayable.SessionStatusUpdate() - session_user_sync = delayable.SessionUserSync() - session_background_executor = delayable.MainThreadExecutor( + session_update = timers.SessionStatusUpdate() + session_user_sync = timers.SessionUserSync() + session_background_executor = timers.MainThreadExecutor( execution_queue=background_execution_queue) session_update.register() @@ -712,6 +722,181 @@ class SessionNotifyOperator(bpy.types.Operator): return context.window_manager.invoke_props_dialog(self) +def dump_db(filepath): + # Replication graph + nodes_ids = session.list() + #TODO: add dump graph to replication + + nodes =[] + for n in nodes_ids: + nd = session.get(uuid=n) + nodes.append(( + n, + { + 'owner': nd.owner, + 'str_type': nd.str_type, + 'data': nd.data, + 'dependencies': nd.dependencies, + } + )) + + db = dict() + db['nodes'] = nodes + db['users'] = copy.copy(session.online_users) + + stime = datetime.now().strftime('%Y_%m_%d_%H-%M-%S') + + filepath = Path(filepath) + filepath = filepath.with_name(f"{filepath.stem}_{stime}{filepath.suffix}") + with gzip.open(filepath, "wb") as f: + logging.info(f"Writing session snapshot to {filepath}") + pickle.dump(db, f, protocol=4) + + +class SessionSaveBackupOperator(bpy.types.Operator, ExportHelper): + bl_idname = "session.save" + bl_label = "Save session data" + bl_description = "Save a snapshot of the collaborative session" + + # ExportHelper mixin class uses this + filename_ext = ".db" + + filter_glob: bpy.props.StringProperty( + default="*.db", + options={'HIDDEN'}, + maxlen=255, # Max internal buffer length, longer would be clamped. + ) + + enable_autosave: bpy.props.BoolProperty( + name="Auto-save", + description="Enable session auto-save", + default=True, + ) + save_interval: bpy.props.FloatProperty( + name="Auto save interval", + description="auto-save interval (seconds)", + default=10, + ) + + def execute(self, context): + if self.enable_autosave: + recorder = timers.SessionBackupTimer( + filepath=self.filepath, + timeout=self.save_interval) + recorder.register() + deleyables.append(recorder) + else: + dump_db(self.filepath) + + return {'FINISHED'} + + @classmethod + def poll(cls, context): + return session.state['STATE'] == STATE_ACTIVE + +class SessionStopAutoSaveOperator(bpy.types.Operator): + bl_idname = "session.cancel_autosave" + bl_label = "Cancel auto-save" + bl_description = "Cancel session auto-save" + + @classmethod + def poll(cls, context): + return (session.state['STATE'] == STATE_ACTIVE and 'SessionBackupTimer' in registry) + + def execute(self, context): + autosave_timer = registry.get('SessionBackupTimer') + autosave_timer.unregister() + + return {'FINISHED'} + + +class SessionLoadSaveOperator(bpy.types.Operator, ImportHelper): + bl_idname = "session.load" + bl_label = "Load session save" + bl_description = "Load a Multi-user session save" + bl_options = {'REGISTER', 'UNDO'} + + # ExportHelper mixin class uses this + filename_ext = ".db" + + filter_glob: bpy.props.StringProperty( + default="*.db", + options={'HIDDEN'}, + maxlen=255, # Max internal buffer length, longer would be clamped. + ) + + def execute(self, context): + from replication.graph import ReplicationGraph + + # TODO: add filechecks + + try: + f = gzip.open(self.filepath, "rb") + db = pickle.load(f) + except OSError as e: + f = open(self.filepath, "rb") + db = pickle.load(f) + + if db: + logging.info(f"Reading {self.filepath}") + nodes = db.get("nodes") + + logging.info(f"{len(nodes)} Nodes to load") + + + + # init the factory with supported types + bpy_factory = ReplicatedDataFactory() + for type in bl_types.types_to_register(): + type_module = getattr(bl_types, type) + name = [e.capitalize() for e in type.split('_')[1:]] + type_impl_name = 'Bl'+''.join(name) + type_module_class = getattr(type_module, type_impl_name) + + + bpy_factory.register_type( + type_module_class.bl_class, + type_module_class) + + graph = ReplicationGraph() + + for node, node_data in nodes: + node_type = node_data.get('str_type') + + impl = bpy_factory.get_implementation_from_net(node_type) + + if impl: + logging.info(f"Loading {node}") + instance = impl(owner=node_data['owner'], + uuid=node, + dependencies=node_data['dependencies'], + data=node_data['data']) + instance.store(graph) + instance.state = FETCHED + + logging.info("Graph succefully loaded") + + utils.clean_scene() + + # Step 1: Construct nodes + for node in graph.list_ordered(): + graph[node].resolve() + + # Step 2: Load nodes + for node in graph.list_ordered(): + graph[node].apply() + + + return {'FINISHED'} + + @classmethod + def poll(cls, context): + return True + +def menu_func_import(self, context): + self.layout.operator(SessionLoadSaveOperator.bl_idname, text='Multi-user session snapshot (.db)') + + classes = ( SessionStartOperator, SessionStopOperator, @@ -726,6 +911,9 @@ classes = ( SessionInitOperator, SessionClearCache, SessionNotifyOperator, + SessionSaveBackupOperator, + SessionLoadSaveOperator, + SessionStopAutoSaveOperator, ) @@ -794,7 +982,7 @@ def depsgraph_evaluation(scene): def register(): from bpy.utils import register_class - for cls in classes: + for cls in classes: register_class(cls) bpy.app.handlers.undo_post.append(sanitize_deps_graph) diff --git a/multi_user/delayable.py b/multi_user/timers.py similarity index 89% rename from multi_user/delayable.py rename to multi_user/timers.py index 3196b98..9fd6dda 100644 --- a/multi_user/delayable.py +++ b/multi_user/timers.py @@ -16,68 +16,48 @@ # ##### END GPL LICENSE BLOCK ##### import logging +import sys import bpy - -from . import utils -from .presence import (renderer, - UserFrustumWidget, - UserNameWidget, - UserSelectionWidget, - refresh_3d_view, - generate_user_camera, - get_view_matrix, - refresh_sidebar_view) -from . import operators -from replication.constants import (FETCHED, - UP, - RP_COMMON, - STATE_INITIAL, - STATE_QUITTING, - STATE_ACTIVE, - STATE_SYNCING, - STATE_LOBBY, - STATE_SRV_SYNC) - -from replication.interface import session +from replication.constants import (FETCHED, RP_COMMON, STATE_ACTIVE, + STATE_INITIAL, STATE_LOBBY, STATE_QUITTING, + STATE_SRV_SYNC, STATE_SYNCING, UP) from replication.exception import NonAuthorizedOperationError +from replication.interface import session +from . import operators, utils +from .presence import (UserFrustumWidget, UserNameWidget, UserSelectionWidget, + generate_user_camera, get_view_matrix, refresh_3d_view, + refresh_sidebar_view, renderer) + +this = sys.modules[__name__] + +# Registered timers +this.registry = dict() def is_annotating(context: bpy.types.Context): """ Check if the annotate mode is enabled """ return bpy.context.workspace.tools.from_space_view3d_mode('OBJECT', create=False).idname == 'builtin.annotate' -class Delayable(): - """Delayable task interface - """ - def register(self): - raise NotImplementedError - - def execute(self): - raise NotImplementedError - - def unregister(self): - raise NotImplementedError - - -class Timer(Delayable): +class Timer(object): """Timer binder interface for blender Run a bpy.app.Timer in the background looping at the given rate """ - def __init__(self, duration=1): - super().__init__() - self._timeout = duration + def __init__(self, timeout=10, id=None): + self._timeout = timeout self.is_running = False + self.id = id if id else self.__class__.__name__ def register(self): """Register the timer into the blender timer system """ if not self.is_running: + this.registry[self.id] = self bpy.app.timers.register(self.main) self.is_running = True logging.debug(f"Register {self.__class__.__name__}") @@ -105,15 +85,26 @@ class Timer(Delayable): """Unnegister the timer of the blender timer system """ if bpy.app.timers.is_registered(self.main): + logging.info(f"Unregistering {self.id}") bpy.app.timers.unregister(self.main) - + + del this.registry[self.id] self.is_running = False +class SessionBackupTimer(Timer): + def __init__(self, timeout=10, filepath=None): + self._filepath = filepath + super().__init__(timeout) + + + def execute(self): + operators.dump_db(self._filepath) class ApplyTimer(Timer): - def __init__(self, timout=1, target_type=None): + def __init__(self, timeout=1, target_type=None): self._type = target_type - super().__init__(timout) + super().__init__(timeout) + self.id = target_type.__name__ def execute(self): if session and session.state['STATE'] == STATE_ACTIVE: @@ -140,8 +131,8 @@ class ApplyTimer(Timer): session.apply(n, force=True) class DynamicRightSelectTimer(Timer): - def __init__(self, timout=.1): - super().__init__(timout) + def __init__(self, timeout=.1): + super().__init__(timeout) self._last_selection = [] self._user = None self._annotating = False @@ -262,8 +253,8 @@ class DynamicRightSelectTimer(Timer): class ClientUpdate(Timer): - def __init__(self, timout=.1): - super().__init__(timout) + def __init__(self, timeout=.1): + super().__init__(timeout) self.handle_quit = False self.users_metadata = {} @@ -325,16 +316,16 @@ class ClientUpdate(Timer): class SessionStatusUpdate(Timer): - def __init__(self, timout=1): - super().__init__(timout) + def __init__(self, timeout=1): + super().__init__(timeout) def execute(self): refresh_sidebar_view() class SessionUserSync(Timer): - def __init__(self, timout=1): - super().__init__(timout) + def __init__(self, timeout=1): + super().__init__(timeout) self.settings = utils.get_preferences() def execute(self): @@ -367,12 +358,12 @@ class SessionUserSync(Timer): class MainThreadExecutor(Timer): - def __init__(self, timout=1, execution_queue=None): - super().__init__(timout) + def __init__(self, timeout=1, execution_queue=None): + super().__init__(timeout) self.execution_queue = execution_queue def execute(self): while not self.execution_queue.empty(): function, kwargs = self.execution_queue.get() logging.debug(f"Executing {function.__name__}") - function(**kwargs) \ No newline at end of file + function(**kwargs) diff --git a/multi_user/ui.py b/multi_user/ui.py index 67fad1a..bca2e0e 100644 --- a/multi_user/ui.py +++ b/multi_user/ui.py @@ -29,6 +29,7 @@ from replication.constants import (ADDED, ERROR, FETCHED, STATE_LAUNCHING_SERVICES) from replication import __version__ from replication.interface import session +from .timers import registry ICONS_PROP_STATES = ['TRIA_DOWN', # ADDED 'TRIA_UP', # COMMITED @@ -563,6 +564,13 @@ class SESSION_PT_repository(bpy.types.Panel): row = layout.row() if session.state['STATE'] == STATE_ACTIVE: + if 'SessionBackupTimer' in registry: + row.alert = True + row.operator('session.cancel_autosave', icon="CANCEL") + row.alert = False + else: + row.operator('session.save', icon="FILE_TICK") + flow = layout.grid_flow( row_major=True, columns=0,