2026-02-16

This commit is contained in:
2026-03-17 15:25:32 -06:00
parent d5dd373de0
commit 60100fbab2
560 changed files with 33397 additions and 20776 deletions
@@ -1,13 +1,16 @@
This add-on makes Blender's autosaves a bit more powerful and reliable.
[Incremental Auto-Save](https://extensions.blender.org/add-ons/incremental-auto-save/) makes Blender's autosaves more configurable and reliable.
![Addon Preferences UI](/docs/incremental_autosave.png "Addon Preferences UI")
I specifically created this add-on to be able to share my user preferences across multiple computers. Normally, when you specify an autosave path in your Blender preferences, and that path does not exist, autosaves will simply not work at all. Also, Blender's autosaves overwrite each other, so you only have one backup per file, making shorter autosave intervals risky, since you might want to go back more than a few minutes in your saves.
### Features:
- Incremental autosave: Automatically save in configurable time intervals, without each autosave overwriting the previous one.
- Incremental auto-save: Automatically save in configurable time intervals, without each autosave overwriting the previous one.
- Customizable configuration: Set the maximum number of saves per file and the interval between saves according to your preferences.
- Autosave on file switch: Automatically save the current file when opening another, ensuring your progress is always backed up.
- Autosave for unsaved files: Automatically save files that have not been saved before, giving them the name "Unnamed.blend".
- Support multiple computers: Specify a list of file paths, where the first valid one will be used for saving backups. Useful when you share your user preferences across multiple computers.
- Invalid path fallback: If no valid specified file is found, create backups next to the current .blend, or the OS temp folder.
- Save next to .blend: Instead of a global auto-save directory, your autosaves can go to an "Autosave" folder next to your .blend.
- Invalid path fallback: If none of the given paths are valid, create backups next to the current .blend, or the OS temp folder.
- Save modified images: For texture painting workflows, this will auto-save your images as well as the .blend file.
- Save Mesh/Sculpt: If you spend a long time in Edit/Sculpt mode and Blender crashes, you will lose your work, because the mesh data is not written back into the object until you go to Object Mode. The add-on will periodically enter object mode for you, without interrupting any Sculpt/Paint/Transform operations.
It goes without saying that this add-on can introduce periodic lag. This is simply the price of saving data to a disk. A slow disk or a large file will exacerbate this, and all you can do is adjust your save interval according to what you can put up with.
@@ -1,26 +1,36 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import json
import os
import tempfile
from datetime import datetime
from pathlib import Path
import bpy
from bl_ui.generic_ui_list import draw_ui_list
from bpy.app.handlers import persistent
from bpy.props import (
BoolProperty,
CollectionProperty,
IntProperty,
StringProperty,
)
from bpy.types import AddonPreferences, PropertyGroup, UIList
from rna_prop_ui import IDPropertyGroup
bl_info = {
"name": "Incremental Autosave",
"author": "Demeter Dzadik",
"version": (1, 1, 0),
"version": (1, 1, 1),
"blender": (2, 90, 0),
"location": "blender",
"description": "Autosaves in a way where subsequent autosaves don't overwrite previous ones",
"category": "System",
}
import os, tempfile, json
from datetime import datetime
from pathlib import Path
import bpy
from bpy.props import BoolProperty, IntProperty, StringProperty, CollectionProperty
from bpy.types import PropertyGroup, AddonPreferences, UIList
from bpy.app.handlers import persistent
from bl_ui.generic_ui_list import draw_ui_list
# Timestamp format for prefixing autosave file names.
TIME_FMT_STR = '%Y-%M-%d_%H-%M'
TIME_FMT_STR = "%Y-%m-%d_%H-%M"
# Timestamp of when Blender is launched. Used to avoid creating an autosave when opening Blender.
LAUNCH_TIME = datetime.now()
@@ -30,47 +40,36 @@ class INCSAVE_UL_file_paths(UIList):
def draw_item(
self, context, layout, data, item, icon_value, active_data, active_propname
):
filepath = item
filepath: AutoSavePath = item
row = layout.row()
split = row.split(factor=0.2)
split.prop(item, 'name', text="")
split.prop(filepath, "name", text="")
row = split.row()
if not os.path.exists(item.path):
if not os.path.exists(filepath.path):
row.alert = True
row.prop(item, 'path', text="")
row.prop(filepath, "path", text="")
def get_addon_prefs(context=None):
context = context or bpy.context
return context.preferences.addons[__name__].preferences
def update_prefs_on_file(self, context):
def update_prefs_on_file(self=None, context=None):
prefs = get_addon_prefs(context)
if not type(prefs).loading:
prefs.save_prefs_to_file()
class AutoSavePath(PropertyGroup):
name: StringProperty(
update=update_prefs_on_file
)
path: StringProperty(
name="Autosave Path",
description="An autosave path. If this filepath doesn't exist, the next existing one will be used. If none are valid, the Blender auto-save path will be used. If that's not valid either, the OS default temp folder will be used",
subtype='FILE_PATH',
default="",
update=update_prefs_on_file
)
class PrefsFileSaveLoadMixin:
"""Mix-in class that can be used by any add-on to store their preferences in a file,
so that they don't get lost when the add-on is disabled.
To use it, just do this:
To use it, copy this class and the function above it, and do this in your code:
```
import bpy
import bpy, json
from pathlib import Path
class MyAddonPrefs(PrefsFileSaveLoadMixin, bpy.types.AddonPreferences):
some_prop: bpy.props.IntProperty(update=update_prefs_on_file)
@@ -78,10 +77,16 @@ class PrefsFileSaveLoadMixin:
def register():
bpy.utils.register_class(MyAddonPrefs)
MyAddonPrefs.register_autoload_from_file()
def unregister():
update_prefs_on_file()
```
"""
# List of property names to not write to disk.
omit_from_disk: list[str] = []
loading = False
@staticmethod
@@ -89,33 +94,48 @@ class PrefsFileSaveLoadMixin:
def timer_func(_scene=None):
prefs = get_addon_prefs()
prefs.load_prefs_from_file()
bpy.app.timers.register(timer_func, first_interval=delay)
@staticmethod
def prefs_to_dict_recursive(propgroup: 'IDPropertyGroup') -> dict:
def prefs_to_dict_recursive(self, propgroup: IDPropertyGroup) -> dict:
"""Recursively convert AddonPreferences to a dictionary.
Note that AddonPreferences don't support PointerProperties,
so this function doesn't either."""
from rna_prop_ui import IDPropertyGroup
ret = {}
if hasattr(propgroup, 'bl_rna'):
rna_class = propgroup.bl_rna
rna_class = None
if isinstance(propgroup, bpy.types.AddonPreferences):
prop_dict = {
key: getattr(propgroup, key)
for key in propgroup.bl_rna.properties.keys()
if key not in ("rna_type", "bl_idname")
}
else:
property_group_class_name = type(propgroup).__name__
rna_class = bpy.types.PropertyGroup.bl_rna_get_subclass_py(property_group_class_name)
rna_class = bpy.types.PropertyGroup.bl_rna_get_subclass_py(
property_group_class_name
)
if not hasattr(rna_class, "properties"):
rna_class = None
prop_dict = {
key: getattr(propgroup, key)
for key in propgroup.bl_rna.properties.keys()
if key not in ("rna_type")
}
this_func = PrefsFileSaveLoadMixin.prefs_to_dict_recursive
for key, value in propgroup.items():
if type(value) == list:
ret[key] = [this_func(elem) for elem in value]
elif type(value) == IDPropertyGroup:
ret[key] = this_func(value)
for key, value in prop_dict.items():
if key in type(self).omit_from_disk:
continue
if type(value) in (list, bpy.types.bpy_prop_collection_idprop):
ret[key] = [self.prefs_to_dict_recursive(elem) for elem in value]
elif type(value) is IDPropertyGroup:
ret[key] = self.prefs_to_dict_recursive(value)
else:
if (
rna_class and
key in rna_class.properties and
hasattr(rna_class.properties[key], 'enum_items')
rna_class
and key in rna_class.properties
and hasattr(rna_class.properties[key], "enum_items")
):
# Save enum values as string, not int.
ret[key] = rna_class.properties[key].enum_items[value].identifier
@@ -123,32 +143,30 @@ class PrefsFileSaveLoadMixin:
ret[key] = value
return ret
@staticmethod
def apply_prefs_from_dict_recursive(propgroup, data):
this_func = PrefsFileSaveLoadMixin.apply_prefs_from_dict_recursive
def apply_prefs_from_dict_recursive(self, propgroup, data):
for key, value in data.items():
if not hasattr(propgroup, key):
# Property got removed or renamed in the implementation.
continue
if type(value) == list:
if type(value) is list:
for elem in value:
collprop = getattr(propgroup, key)
entry = collprop.get(elem['name'])
entry = collprop.get(elem["name"])
if not entry:
entry = collprop.add()
this_func(entry, elem)
elif type(value) == dict:
this_func(getattr(propgroup, key), value)
self.apply_prefs_from_dict_recursive(entry, elem)
elif type(value) is dict:
self.apply_prefs_from_dict_recursive(getattr(propgroup, key), value)
else:
setattr(propgroup, key, value)
@staticmethod
def get_prefs_filepath() -> Path:
addon_name = __package__.split(".")[-1]
return Path(bpy.utils.user_resource('CONFIG')) / Path(addon_name + ".txt")
return Path(bpy.utils.user_resource("CONFIG")) / Path(addon_name + ".txt")
def save_prefs_to_file(self, _context=None):
data_dict = self.prefs_to_dict_recursive(self)
data_dict = self.prefs_to_dict_recursive(propgroup=self)
with open(self.get_prefs_filepath(), "w") as f:
json.dump(data_dict, f, indent=4)
@@ -163,24 +181,38 @@ class PrefsFileSaveLoadMixin:
type(self).loading = True
try:
self.apply_prefs_from_dict_recursive(self, addon_data)
except Exception as exc:
except Exception:
# If we get an error raised here, and it isn't handled,
# the add-on seems to break.
print(f"Failed to load {__package__} preferences from file.")
# raise exc
type(self).loading = False
class AutoSavePath(PropertyGroup):
name: StringProperty(update=update_prefs_on_file)
path: StringProperty(
name="Autosave Path",
description="An autosave path. If this filepath doesn't exist, the next existing one will be used. If none are valid, the Blender auto-save path will be used. If that's not valid either, the OS default temp folder will be used",
subtype="FILE_PATH",
default="",
update=update_prefs_on_file,
)
class IncrementalAutoSavePreferences(PrefsFileSaveLoadMixin, AddonPreferences):
bl_idname = __name__
omit_from_disk = ["active_index"]
save_before_close: BoolProperty(
name='Save Before File Open',
description='Save the current file before opening another file',
name="Save Before File Open",
description="Save the current file before opening another file",
default=True,
update=update_prefs_on_file,
)
save_interval: IntProperty(
name='Save Interval (Minutes)',
name="Save Interval (Minutes)",
description="Number of minutes between each save while the add-on is enabled",
default=5,
min=1,
@@ -190,25 +222,53 @@ class IncrementalAutoSavePreferences(PrefsFileSaveLoadMixin, AddonPreferences):
)
max_save_files: bpy.props.IntProperty(
name='Max Backups Per File',
description='Maximum number of backups to save for each file, 0 means unlimited. Otherwise, the oldest file will be deleted after reaching the limit',
name="Max Backups Per File",
description="Maximum number of backups to save for each file, 0 means unlimited. Otherwise, the oldest file will be deleted after reaching the limit",
default=10,
min=0,
max=100,
update=update_prefs_on_file,
)
compress_files: bpy.props.BoolProperty(
name='Compress Files',
description='Save backups with compression enabled',
name="Compress Files",
description="Save backups with compression enabled",
default=True,
update=update_prefs_on_file,
)
autosave_paths: CollectionProperty(type=AutoSavePath)
relative_to_blend: BoolProperty(
name="Save Next To Blend",
description='When the .blend file is saved, save auto-saves next to it, into an "Autosave" folder.\nIf the .blend is not saved, it will fall back to the list of filepaths provided above, or system temp folder.',
default=False,
)
save_images: BoolProperty(
name="Save Images",
description="Useful for long periods of texture painting. This will auto-save images - both packed AND UNPACKED! Best to use in conjunction with a version control system like GitLFS/SVN!",
default=True,
)
save_sculpt: BoolProperty(
name="Save Mesh/Sculpt",
description="Useful for long periods of mesh editing/sculpting. Normally if Blender crashes during mesh edit/sculpt, you lose your changes since the last time you entered Object mode. If this is enabled, the add-on will periodically switch to Object mode for you, and back. This will only trigger after dependency graph updates, so it doesn't happen in the middle of a stroke.",
default=True,
)
print_saves: BoolProperty(
name="Print Saves",
description="Print in the System Console (aka Terminal) on each autosave.",
default=True,
)
active_index: IntProperty()
def get_valid_autosave_path(self, context):
def get_relative_dir(self) -> str:
assert bpy.data.filepath
return os.sep.join((os.path.dirname(bpy.data.filepath), "Autosaves"))
def get_valid_autosave_path(self, context) -> str:
"""Return an autosave path that will always actually exist, no matter how desperate."""
prefs = get_addon_prefs(context)
if bpy.data.filepath and prefs.relative_to_blend:
return self.get_relative_dir()
# Try the native autosave path first.
default_path = bpy.context.preferences.filepaths.temporary_directory
if os.path.exists(default_path):
@@ -219,9 +279,9 @@ class IncrementalAutoSavePreferences(PrefsFileSaveLoadMixin, AddonPreferences):
if os.path.exists(path.path):
return path.path
# If none of those exist, return the .blend's directory.
# If none of those exist, return Autosave dir next to .blend.
if bpy.data.filepath:
return os.path.dirname(bpy.data.filepath)
return self.get_relative_dir()
# And if that doesn't exist either, fall back to the sys temp dir.
sys_temp = tempfile.gettempdir()
@@ -232,53 +292,60 @@ class IncrementalAutoSavePreferences(PrefsFileSaveLoadMixin, AddonPreferences):
layout.use_property_decorate = False
layout.use_property_split = True
split = layout.split(factor=0.4)
split.row()
split.label(text="First valid path will be used:")
header, panel = layout.panel("Incremental Autosave: Paths", default_closed=True)
header.label(text="Autosave Paths")
if panel:
split = panel.split(factor=0.4)
split.row()
split.label(text="First valid path will be used:")
native_row = layout.row()
if not os.path.exists(context.preferences.filepaths.temporary_directory):
native_row.alert = True
native_row.prop(
context.preferences.filepaths,
'temporary_directory',
text="Native Autosave Path",
)
native_row = panel.row()
if not os.path.exists(context.preferences.filepaths.temporary_directory):
native_row.alert = True
native_row.prop(
context.preferences.filepaths,
"temporary_directory",
text="Native Autosave Path",
)
draw_ui_list(
layout,
context,
class_name='INCSAVE_UL_file_paths',
list_path=f'preferences.addons["{__package__}"].preferences.autosave_paths',
active_index_path=f'preferences.addons["{__package__}"].preferences.active_index',
insertion_operators=True,
move_operators=True,
unique_id='Incremental Autosave Path List',
)
draw_ui_list(
panel,
context,
class_name="INCSAVE_UL_file_paths",
list_path=f'preferences.addons["{__package__}"].preferences.autosave_paths',
active_index_path=f'preferences.addons["{__package__}"].preferences.active_index',
insertion_operators=True,
move_operators=True,
unique_id="Incremental Autosave Path List",
)
layout.separator()
panel.prop(self, "relative_to_blend")
split = layout.split(factor=0.4)
split.row()
split.label(
text="Current autosave path: " + str(self.get_valid_autosave_path(context))
)
row = split.row()
row.alignment = "RIGHT"
row.label(text="Current Autosave Path")
split.label(text=str(self.get_valid_autosave_path(context)))
layout.separator()
layout.prop(self, 'save_interval')
layout.prop(self, 'max_save_files')
layout.prop(self, 'save_before_close')
layout.prop(self, "save_interval")
layout.prop(self, "max_save_files")
layout.prop(self, "save_before_close")
layout.separator()
layout.prop(self, 'compress_files')
layout.prop(self, "compress_files")
layout.prop(self, "print_saves")
layout.separator()
layout.prop(self, "save_images")
layout.prop(self, "save_sculpt")
def save_file():
addon_prefs = get_addon_prefs()
basename = bpy.data.filepath
if basename == '':
basename = 'Unnamed.blend'
if basename == "":
basename = "Unnamed.blend"
else:
basename = bpy.path.basename(basename)
@@ -286,8 +353,10 @@ def save_file():
save_dir = bpy.path.abspath(addon_prefs.get_valid_autosave_path(bpy.context))
if not os.path.isdir(save_dir):
os.mkdir(save_dir)
except:
print("Incremental Autosave: Error creating auto save directory.")
except PermissionError:
print(
f"Incremental Autosave: No permission to create auto save directory:\n{save_dir}"
)
return
# Delete old files, to limit the number of saves.
@@ -304,20 +373,51 @@ def save_file():
old_file = os.path.join(save_dir, otherfiles[0])
os.remove(old_file)
otherfiles.pop(0)
except:
except PermissionError:
print("Incremental Autosave: Unable to remove old files.")
# Save the copy.
time = datetime.now()
filename = time.strftime(TIME_FMT_STR) + '_' + basename
filename = time.strftime(TIME_FMT_STR) + "_" + basename
backup_file = os.path.join(save_dir, filename)
try:
if addon_prefs.save_images:
save_images()
if addon_prefs.save_sculpt:
save_sculpt()
bpy.ops.wm.save_as_mainfile(
filepath=backup_file, copy=True, compress=addon_prefs.compress_files
)
print("Incremental Autosave: Saved file: ", backup_file)
except:
print('Incremental Autosave: Error auto saving file.')
if addon_prefs.print_saves:
print(f"Incremental Autosave: Saved file: {backup_file}")
except PermissionError:
print(f"Incremental Autosave: Error auto saving file: {backup_file}")
def save_images():
for img in bpy.data.images:
if not img.is_dirty:
continue
try:
if img.source == "GENERATED" or img.packed_file:
img.pack()
else:
img.save()
except Exception:
# Should never happen, but I don't trust this API.
# https://projects.blender.org/blender/blender/issues/152638
print(f"Incremental Autosave: Failed to save dirty image: {img.name}")
def save_sculpt():
"""We just need to enter object mode and then go back to the original mode."""
context = bpy.context
if context.mode == "EDIT_MESH":
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.mode_set(mode="EDIT")
if context.mode == "SCULPT":
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.mode_set(mode="SCULPT")
@persistent
@@ -327,20 +427,43 @@ def save_before_close(_dummy=None):
save_file()
def create_autosave():
def create_autosave_on_timer():
now = datetime.now()
delta = now - LAUNCH_TIME
prefs = get_addon_prefs()
if delta.seconds < 5:
return get_addon_prefs().save_interval * 60
return prefs.save_interval * 60
if bpy.data.is_dirty:
save_file()
return get_addon_prefs().save_interval * 60
bpy.app.timers.register(save_after_modal_operation)
return prefs.save_interval * 60
def save_after_modal_operation():
"""Mesh edits, sculpt strokes, and texture paint strokes trigger a depsgraph update,
so it's better to make a save after one of those operations, rather than on a timer.
"""
context = bpy.context
for modal in context.window.modal_operators:
# We probably don't want to save in the middle of a transform/sculpt operation.
if any(
modal.bl_idname.startswith(prefix)
for prefix in [
"TRANSFORM_OT_",
"SCULPT_OT_",
"PAINT_OT_",
]
):
return 0.5
save_file()
@persistent
def register_autosave_timer(_dummy=None):
bpy.app.timers.register(create_autosave)
bpy.app.timers.register(create_autosave_on_timer)
def register():
@@ -348,16 +471,17 @@ def register():
bpy.utils.register_class(AutoSavePath)
bpy.utils.register_class(IncrementalAutoSavePreferences)
IncrementalAutoSavePreferences.register_autoload_from_file()
bpy.app.timers.register(create_autosave)
bpy.app.timers.register(create_autosave_on_timer)
bpy.app.handlers.load_pre.append(save_before_close)
bpy.app.handlers.load_post.append(register_autosave_timer)
def unregister():
save_before_close()
update_prefs_on_file()
bpy.app.handlers.load_pre.remove(save_before_close)
bpy.app.handlers.load_post.remove(register_autosave_timer)
bpy.app.timers.unregister(create_autosave)
bpy.app.timers.unregister(create_autosave_on_timer)
bpy.utils.unregister_class(IncrementalAutoSavePreferences)
bpy.utils.unregister_class(AutoSavePath)
bpy.utils.unregister_class(INCSAVE_UL_file_paths)
@@ -1,21 +1,21 @@
schema_version = "1.0.0"
id = "incremental_auto_save"
version = "1.1.0"
version = "1.1.1"
name = "Incremental Auto-Save"
tagline = "Improvements to Blender's Autosave"
maintainer = "Demeter Dzadik <demeter@blender.org>"
type = "add-on"
website = "https://projects.blender.org/Mets/CloudRig"
website = "https://projects.blender.org/Mets/incremental-autosave"
tags = ["System"]
blender_version_min = "4.2.0"
license = [
"SPDX:GPL-3.0-or-later",
"SPDX:GPL-3.0-or-later"
]
copyright = [
"2019-2024 Demeter Dzadik",
"2019-2024 Demeter Dzadik"
]
[permissions]
files = "Save preferences & .blends in chosen directories"