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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,6 +4,11 @@
## [Unreleased]
## [0.5.1] - 2026-02-07
- Fix bug caused by a change in Blender 5.0's Python API for creating drivers. It caused the location component of the shake to not animate.
## [0.5.0] - 2025-02-10
- Update code to work properly with the new slotted/layered Action APIs in Blender 4.4. Otherwise functionally identical to v0.4.0.
@@ -31,7 +36,8 @@ To be filled out.
To be filled out.
[Unreleased]: https://github.com/cessen/colorbox/compare/v0.5.0...HEAD
[Unreleased]: https://github.com/cessen/colorbox/compare/v0.5.1...HEAD
[0.5.1]: https://github.com/cessen/colorbox/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/cessen/colorbox/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/cessen/colorbox/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/cessen/colorbox/compare/v0.2.0...v0.3.0
@@ -284,9 +284,16 @@ def build_single_shake(camera, shake_item_index, collection, context):
rot_constraint.mix_mode = 'AFTER'
# Set up the location constraint driver.
driver = loc_constraint.driver_add("influence").driver
#
# Note: we clear the keyframes from the driver's fcurve to dodge some
# small-value rounding that Blender does internally when evaluating fcurves.
# This way the driver expression evaluation gets used directly, without any
# intermediate steps that might interfere.
fcurve = loc_constraint.driver_add("influence")
fcurve.keyframe_points.clear()
driver = fcurve.driver
driver.type = 'SCRIPTED'
driver.expression = "{} * influence * location_scale / unit_scale".format(1.0 / (UNIT_SCALE_MAX * INFLUENCE_MAX * SCALE_MAX))
driver.expression = "{} * influence * location_scale / unit_scale * int(\"1\")".format(1.0 / (UNIT_SCALE_MAX * INFLUENCE_MAX * SCALE_MAX))
if "influence" not in driver.variables:
var = driver.variables.new()
var.name = "influence"
@@ -310,7 +317,11 @@ def build_single_shake(camera, shake_item_index, collection, context):
var.targets[0].data_path ='unit_settings.scale_length'
# Set up the rotation constraint driver.
driver = rot_constraint.driver_add("influence").driver
#
# Note: see further-above note for why we clear the keyframes here.
fcurve = rot_constraint.driver_add("influence")
fcurve.keyframe_points.clear()
driver = fcurve.driver
driver.type = 'SCRIPTED'
driver.expression = "influence * {}".format(1.0 / INFLUENCE_MAX)
if "influence" not in driver.variables:
@@ -633,7 +644,7 @@ def register():
# The list of camera shakes active on an camera, along with each shake's parameters.
bpy.types.Object.camera_shakes = bpy.props.CollectionProperty(type=CameraShakeInstance)
bpy.types.Object.camera_shakes_active_index = bpy.props.IntProperty(name="Camera Shake List Active Item Index")
bpy.types.Object.camera_shakes_active_index = bpy.props.IntProperty(name="Camera Shake List Active Item Index", options = set())
bpy.types.WindowManager.camera_shake_show_utils = bpy.props.BoolProperty(name="Show Camera Shake Utils UI", default=False)
@@ -1,7 +1,7 @@
schema_version = "1.0.0"
id = "camera_shakify"
version = "0.5.0"
version = "0.5.1"
name = "Camera Shakify"
tagline = "Add captured camera shake/wobble to your cameras"
maintainer = "Nathan Vegadahl <cessen@cessen.com>"
@@ -1,6 +1,6 @@
schema_version = "1.0.0"
id = "datablock_utils"
version = "1.2.3"
version = "1.3.0"
name = "Data-Block Utilities"
tagline = "Show users, merge duplicates, find similar, and more"
maintainer = "Leonardo Pike-Excell <leonardopike.excell@gmail.com>"
@@ -143,7 +143,8 @@ class NodeProperties:
self._add_link(root_link, node_map)
continue
if not links[socket].from_node.mute:
from_node = links[socket].from_node
if not from_node.mute and from_node.name in node_map:
self._add_link(links[socket], node_map)
continue
@@ -52,15 +52,6 @@ def get_path_to_light(
return get_path_to_light(nested_users, users[0]) # type: ignore
def get_node_editor() -> tuple[bpy.types.Area, bpy.types.Region]:
assert bpy.context
areas = [a for a in bpy.context.window.screen.areas if a.type == 'NODE_EDITOR']
area = areas[0] if len(areas) == 1 else next(
a for a in areas if not cast(SpaceNodeEditor, a.spaces[0]).pin)
region = next(r for r in area.regions if r.type == 'WINDOW')
return area, region
def get_geometry_node_group(
space: SpaceNodeEditor,
id_data: bpy.types.GeometryNodeTree,
@@ -198,15 +189,34 @@ class DBU_OT_GoToDatablock(Operator):
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
assert bpy.context
areas = [a for a in context.window.screen.areas if a.type == 'NODE_EDITOR']
try:
area, region = get_node_editor()
except StopIteration:
if not areas:
self.report({'WARNING'}, "Node editor not open")
return {'FINISHED'}
area.ui_type = 'GeometryNodeTree' if is_geo else 'ShaderNodeTree'
target_ui_type = 'GeometryNodeTree' if is_geo else 'ShaderNodeTree'
if len(areas) == 1:
area = areas[0]
else:
unpinned_areas = [a for a in areas if not cast(SpaceNodeEditor, a.spaces[0]).pin]
if not unpinned_areas:
self.report({'WARNING'}, "No unpinned node editor")
return {'FINISHED'}
area = next(
(a for a in unpinned_areas if a.ui_type == target_ui_type),
unpinned_areas[0],
)
area.ui_type = target_ui_type
region = next(r for r in area.regions if r.type == 'WINDOW')
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
assert bpy.context
with bpy.context.temp_override(area=area, region=region):
space = cast(SpaceNodeEditor, context.space_data)
@@ -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"
@@ -0,0 +1,278 @@
name: Full Build (Espeak + Blender Addon)
on:
workflow_dispatch:
jobs:
build-windows:
name: Build Espeak NG for Windows
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Clone Espeak NG repo
- name: Clone forked espeak NG
# Clone fork containing a fix to filter out mbrola voices on Windows
# Without it, phonemizer will try to use unexisting voices
run: git clone --depth 1 -b fix-windows-list-voices https://github.com/Charley3d/espeak-ng.git
# Clone pcaudiolib repo (required for Windows build)
- name: Clone pcaudiolib
run: git clone --depth 1 https://github.com/espeak-ng/pcaudiolib.git espeak-ng/src/pcaudiolib
- name: Modify config.h
working-directory: espeak-ng
shell: bash
run: |
CONFIG_FILE=src/windows/config.h
echo "Updating $CONFIG_FILE..."
# Add or update the definitions
sed -i "s/^#define USE_KLATT.*/#define USE_KLATT 0/" $CONFIG_FILE || echo "#define USE_KLATT 0" >> $CONFIG_FILE
sed -i "s/^#define USE_SPEECHPLAYER.*/#define USE_SPEECHPLAYER 0/" $CONFIG_FILE || echo "#define USE_SPEECHPLAYER 0" >> $CONFIG_FILE
sed -i "s/^#define USE_MBROLA.*/#define USE_MBROLA 0/" $CONFIG_FILE || echo "#define USE_MBROLA 0" >> $CONFIG_FILE
sed -i "s/^#define USE_SONIC.*/#define USE_SONIC 0/" $CONFIG_FILE || echo "#define USE_SONIC 0" >> $CONFIG_FILE
sed -i "s/^#define USE_ASYNC.*/#define USE_ASYNC 0/" $CONFIG_FILE || echo "#define USE_ASYNC 0" >> $CONFIG_FILE
# Build Espeak NG for Windows
- name: Build with MSBuild
working-directory: espeak-ng
shell: cmd
run: |
cd src/windows
"%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe > msbuild_path.txt
set /p MSBUILD_PATH=<msbuild_path.txt
call "%MSBUILD_PATH%" espeak-ng.sln /p:Configuration=Release
# Copy .dll to temporary dist dir
- name: Copy Windows DLL + Data
run: |
mkdir dist
copy espeak-ng\src\windows\x64\Release\libespeak-ng.dll dist\
# Zip artifacts
- name: Zip Windows build
shell: pwsh
run: |
Compress-Archive -Path dist\* -DestinationPath espeak-ng-windows.zip
# Upload ZIP artifacts
- name: Upload Windows Build of Espeak NG
uses: actions/upload-artifact@v4
with:
name: espeak-ng-windows
path: espeak-ng-windows.zip
build-linux:
name: Build Espeak NG for linux
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Clone forked eSpeak NG
run: git clone --depth 1 -b fix-windows-list-voices https://github.com/Charley3d/espeak-ng.git
# Step 2: Install Build Dependencies
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y make autoconf automake libtool pkg-config
sudo apt-get install -y gcc g++
sudo apt-get install -y libpcaudio-dev
# Build & Copy lib and data folder to temporary dist dir
- name: Building
working-directory: espeak-ng
run: |
./autogen.sh
./configure --with-klatt=no --with-speechplayer=no --with-mbrola=no --with-sonic=no --with-async=no
make
mkdir -p ../dist
cp src/.libs/libespeak-ng.so* ../dist/
zip -r ../espeak-ng-data.zip espeak-ng-data
cd ../dist
zip -r ../espeak-ng-linux.zip .
# Upload ZIP artifacts
- name: Upload Linux lib
uses: actions/upload-artifact@v4
with:
name: espeak-ng-linux
path: espeak-ng-linux.zip
# Upload ZIP artifacts
- name: Upload espeak data folder
uses: actions/upload-artifact@v4
with:
name: espeak-ng-data
path: espeak-ng-data.zip
build-arm64:
name: Build Espeak NG for macOS (arm64)
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Clone forked eSpeak NG
run: git clone --depth 1 -b fix-windows-list-voices https://github.com/Charley3d/espeak-ng.git
# Step 2: Install Build Dependencies
- name: Install dependencies on macOS
run: |
brew update
brew install make gcc autoconf automake libtool pkg-config portaudio
- name: Building arm64
working-directory: espeak-ng
run: |
./autogen.sh
./configure CFLAGS="-arch arm64" LDFLAGS="-arch arm64" --with-klatt=no --with-speechplayer=no --with-mbrola=no --with-sonic=no --with-async=no
make
cp src/.libs/libespeak-ng.dylib ../libespeak-ng-arm64.dylib
- name: Upload arm64 lib + data
uses: actions/upload-artifact@v4
with:
name: espeak-ng-arm64
path: libespeak-ng-arm64.dylib
build-x86_64:
name: Build Espeak NG for macOS (x86_64)
runs-on: macos-13
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Clone forked eSpeak NG
run: git clone --depth 1 -b fix-windows-list-voices https://github.com/Charley3d/espeak-ng.git
# Step 2: Install Build Dependencies
- name: Install dependencies on macOS
run: |
brew update
brew install make gcc autoconf automake libtool pkg-config portaudio
- name: Building x86_64
working-directory: espeak-ng
run: |
./autogen.sh
./configure CFLAGS="-arch x86_64" LDFLAGS="-arch x86_64" --with-klatt=no --with-speechplayer=no --with-mbrola=no --with-sonic=no --with-async=no
make
cp src/.libs/libespeak-ng.dylib ../libespeak-ng-x86_64.dylib
- name: Upload x86_64 lib
uses: actions/upload-artifact@v4
with:
name: espeak-ng-x86_64
path: libespeak-ng-x86_64.dylib
merge:
name: Create Universal macOS Build
runs-on: macos-latest
needs: [build-arm64, build-x86_64]
steps:
- name: Download arm64 lib + data
uses: actions/download-artifact@v4
with:
name: espeak-ng-arm64
- name: Download x86_64 lib
uses: actions/download-artifact@v4
with:
name: espeak-ng-x86_64
- name: Merge with lipo and prepare bundle
run: |
mkdir -p dist
lipo -create -output dist/libespeak-ng.dylib \
libespeak-ng-arm64.dylib \
libespeak-ng-x86_64.dylib
file dist/libespeak-ng.dylib
cd dist
zip -r ../espeak-ng-darwin.zip .
- name: Upload Universal lib
uses: actions/upload-artifact@v4
with:
name: espeak-ng-darwin
path: espeak-ng-darwin.zip
collect-artifacts:
name: Make Blender Addon
runs-on: ubuntu-latest
needs: [ build-windows, merge, build-linux ]
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Install Blender via Snap
run: sudo snap install blender --classic
- name: Download eSpeak Data Folder
uses: actions/download-artifact@v4
with:
name: espeak-ng-data
path: temp/espeak-ng-data
# Download each platforms build artifact
- name: Download Windows build
uses: actions/download-artifact@v4
with:
name: espeak-ng-windows
path: temp/windows
- name: Download macOS build
uses: actions/download-artifact@v4
with:
name: espeak-ng-darwin
path: temp/macos
- name: Download Linux build
uses: actions/download-artifact@v4
with:
name: espeak-ng-linux
path: temp/linux
# Move them to Assets/{platform}
- name: Move artifacts to Assets
run: |
mkdir -p Assets/Archives/windows Assets/Archives/darwin Assets/Archives/linux
cp -r temp/windows/* Assets/Archives/windows/
cp -r temp/macos/* Assets/Archives/darwin/
cp -r temp/linux/* Assets/Archives/linux/
mkdir -p Assets/Archives/common && cp -r temp/espeak-ng-data/* Assets/Archives/common/
# Remove temp files and folders + unwanted files for release
- name: Clean up folder to zip Blender Addon
run: |
rm -rf temp || true
rm -rf dist || true
rm -f .gitignore || true
rm -f dev_tools.py || true
rm -rf .github || true
rm -rf .idea || true
- name: Build Extension with blender
run: blender --command extension build
- name: Get generated zip filename
id: get-zip
run: echo "ZIP_NAME=$(basename $(ls ./*.zip))" >> $GITHUB_OUTPUT
# Upload the full release of Blender Add-on
- name: Upload Blender Addon
uses: actions/upload-artifact@v4
with:
name: lipsync-addon
path: ${{ steps.get-zip.outputs.ZIP_NAME }}
@@ -0,0 +1,118 @@
name: Publish Release
on:
push:
branches: [ master ]
workflow_dispatch:
jobs:
build-extension:
permissions:
contents: write # to be able to publish a GitHub release
issues: write # to be able to comment on released issues
pull-requests: write # to be able to comment on released pull requests
name: Blender Extension Build
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Get next version from semantic-release
id: next_version
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
OUTPUT=$(npx semantic-release --dry-run 2>&1 || true)
echo "$OUTPUT"
VERSION=$(echo "$OUTPUT" | grep -oP 'The next release version is \K[0-9]+\.[0-9]+\.[0-9]+' || true)
if [ -z "$VERSION" ]; then
echo "⚠️ No new version detected (probably no semantic commits)."
exit 0
fi
echo "✅ Found next version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Set up Python
if: steps.next_version.outputs.version != ''
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install tomlkit
if: steps.next_version.outputs.version != ''
run: pip install tomlkit
- name: Update version in Blender Manifest
if: steps.next_version.outputs.version != ''
run: |
python scripts/update_version.py ${{ steps.next_version.outputs.version }}
- name: Install Blender via Snap
run: sudo snap install blender --classic
- name: Build Extension with blender
run: blender --command extension build --split-platforms
# Upload the full release of Blender Add-on
- name: Clean platform-specific archives
run: |
for zipfile in iocgpoly_lip_sync-*-*.zip; do
echo "Examining $zipfile"
unzip -l "$zipfile" | grep -i "archives"
# Extract platform from filename (between last - and _)
platform=$(echo $zipfile | sed 's/.*-\(.*\)_.*/\1/')
echo "Processing $zipfile (platform: $platform)"
# Create temp dir
temp_dir="${platform}_temp"
unzip -q "$zipfile" -d "$temp_dir"
# List the actual directory structure
echo "Directory structure in temp_dir:"
ls -la "$temp_dir/Assets/Archives/"
rm "$zipfile"
cd "$temp_dir"
# Remove irrelevant platform folders based on the platform
case $platform in
linux)
rm -rf Assets/Archives/darwin Assets/Archives/windows
;;
macos)
rm -rf Assets/Archives/linux Assets/Archives/windows
;;
windows)
rm -rf Assets/Archives/darwin Assets/Archives/linux
;;
esac
# List what remains
echo "Remaining directories:"
ls -la Assets/Archives/
# Rezip with same name
zip -r "../$zipfile" .
cd ..
rm -rf "$temp_dir"
done
- name: Semantic release
# if: github.event_name == 'push' && github.ref == 'refs/heads/master'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx semantic-release --debug true
- name: Upload Blender Addon
if: ${{ github.event_name == 'workflow_dispatch' || steps.next_version.outputs.version == '' }}
uses: actions/upload-artifact@v4
with:
name: extension-builds
path: ./*.zip
@@ -0,0 +1,36 @@
name: Upload to Extensions Platform
on:
# release:
# types: [published]
workflow_dispatch:
jobs:
upload-to-extensions:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Download Release Assets and Notes
run: |
mkdir assets
gh release download -p "*.zip" -D assets/
RELEASE_NOTES=$(gh release view --json body | jq -r .body)
echo "$RELEASE_NOTES" > release_notes.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload to Extensions Platform
env:
EXTENSION: iocgpoly_lip_sync
run: |
for zipfile in assets/*.zip; do
echo "Uploading $zipfile to Blender Extensions Platform"
curl -X POST https://extensions.blender.org/api/v1/extensions/${EXTENSION}/versions/upload/ \
-H "Authorization:bearer ${{ secrets.BLENDER_EXTENSIONS_TOKEN }}" \
-F "version_file=@${zipfile}" \
-F "release_notes=<release_notes.txt"
done
@@ -0,0 +1,19 @@
{
"repositoryUrl": "git@github.com:Charley3d/lip-sync.git",
"debug": true,
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/github",
{
"assets": [
{ "path": "iocgpoly_lip_sync-*-linux_x64.zip", "label": "LipSync for Linux ${nextRelease.gitTag}" },
{ "path": "iocgpoly_lip_sync-*-macos_arm64.zip", "label": "LipSync for MacOS arm64 ${nextRelease.gitTag}" },
{ "path": "iocgpoly_lip_sync-*-macos_x64.zip", "label": "LipSync for MacOS x64 ${nextRelease.gitTag}" },
{ "path": "iocgpoly_lip_sync-*-windows_x64.zip", "label": "LipSync for Windows x64 ${nextRelease.gitTag}" }
]
}
]
]
}
@@ -177,14 +177,43 @@ class LIPSYNC2D_PoseAssetsAnimator:
:param interpolation: The interpolation type for the keyframes.
:type interpolation: Literal["LINEAR"]
"""
# Determine where to read the pose asset fcurves from. In newer Blender
# APIs pose assets may not expose `.fcurves` directly; the pose data is
# stored in the action's first layer/strip channelbag. Fall back to
# `pose_action.fcurves` when available.
pose_fcurves = None
# Try to get fcurves from the pose asset's strip channelbag first
try:
if hasattr(pose_action, "layers") and len(pose_action.layers) > 0:
pose_strip = pose_action.layers[0].strips[0]
# Use first slot if present
pose_slot = pose_action.slots[0] if getattr(pose_action, "slots", None) else None
if pose_strip is not None and pose_slot is not None:
pose_channelbag = pose_strip.channelbag(pose_slot)
pose_fcurves = getattr(pose_channelbag, "fcurves", None)
except Exception:
pose_fcurves = None
# Fallback to action.fcurves when present
if pose_fcurves is None:
pose_fcurves = getattr(pose_action, "fcurves", None)
if pose_fcurves is None:
# Nothing we can copy from
return
for fcurve in self.channelbag.fcurves:
pose_asset_fcurve = pose_action.fcurves.find(
pose_asset_fcurve = pose_fcurves.find(
fcurve.data_path, index=fcurve.array_index
)
if pose_asset_fcurve is None:
continue
# Since Action is from a Pose Asset, we can safely assume that first keyframe point holds the Pose
if len(pose_asset_fcurve.keyframe_points) == 0:
continue
fcurve_value = pose_asset_fcurve.keyframe_points[0].co.y
kframe = fcurve.keyframe_points.insert(
frame,
@@ -57,11 +57,29 @@ class LIPSYNC_SpriteSheetAnimator:
:type obj: BpyObject
:return: None
"""
action = obj.animation_data.action if obj.animation_data else None
if action:
for fcurve in action.fcurves:
if (action := obj.animation_data.action) is None:
return
# Retrieve the strip and channelbag correctly for Layered Actions
# Assuming single layer/strip structure as per setup()
if not action.layers or not action.layers[0].strips:
return
strip = cast(BpyActionKeyframeStrip, action.layers[0].strips[0])
# Ensure we get the correct slot for sprite sheet
slot = action.slots.get(f"OB{SLOT_SPRITE_SHEET_NAME}")
if not slot:
return
try:
channelbag = strip.channelbag(slot)
for fcurve in channelbag.fcurves:
if fcurve.data_path == "lipsync2d_props.lip_sync_2d_sprite_sheet_index":
fcurve.keyframe_points.clear()
except Exception:
# Fallback or silence if channelbag creation fails (though it should exist if we are clearing)
pass
def insert_keyframes(self, obj: BpyObject, props: BpyPropertyGroup, visemes_data: VisemeData,
word_timing: WordTiming,
@@ -203,12 +221,29 @@ class LIPSYNC_SpriteSheetAnimator:
:type obj: BpyObject
:return: None
"""
action = obj.animation_data.action if obj.animation_data else None
if (action := obj.animation_data.action) is None:
return
# We can try to use self.channelbag if it's already set up, but to be robust
# we re-fetch it similar to clear_previous_keyframes to ensure we have the right one.
if not action.layers or not action.layers[0].strips:
return
if action:
for fcurve in action.fcurves:
strip = cast(BpyActionKeyframeStrip, action.layers[0].strips[0])
# Ensure we get the correct slot for sprite sheet
slot = action.slots.get(f"OB{SLOT_SPRITE_SHEET_NAME}")
if not slot:
return
try:
channelbag = strip.channelbag(slot)
for fcurve in channelbag.fcurves:
for keyframe in fcurve.keyframe_points:
keyframe.interpolation = 'CONSTANT'
except Exception:
pass
def setup(self, obj: BpyObject):
self.setup_animation_properties(obj)
@@ -53,8 +53,9 @@ phoneme_to_viseme_arkit_v2 = {
"ŋ": "nn",
"ɲ": "nn",
"ɳ": "nn",
"l": "nn",
"ɫ": "nn",
# LL lateral group
"l": "LL",
"ɫ": "LL",
# aa open and low/mid front vowels
"a": "aa",
"aː": "aa",
@@ -119,7 +120,8 @@ def viseme_items_mpeg4_v2(self, context):
("kk", "kk", "K, G (velar stops)"),
("CH", "CH", "CH, J (affricates)"),
("SS", "SS", "S, Z, SH, ZH (narrow fricatives)"),
("nn", "nn", "N, NG, L (nasals and laterals)"),
("nn", "nn", "N, NG (nasals)"),
("LL", "LL", "L (lateral)"),
("RR", "RR", "R (r-like sounds)"),
("aa", "aa", "A, Æ (open/low vowels)"),
("E", "E", "E, Ø, Ə (mid front vowels)"),
@@ -141,6 +143,7 @@ def phonemes_to_default_sprite_index():
"CH": 10,
"SS": 2,
"nn": 10,
"LL": 10,
"RR": 3,
"aa": 11,
"E": 8,
@@ -62,7 +62,26 @@ class LIPSYNC2D_OT_AnalyzeAudio(bpy.types.Operator):
return {"CANCELLED"}
self.set_bake_range()
file_path = extract_audio()
props = context.active_object.lipsync2d_props # type: ignore
target_channel = props.lip_sync_2d_bake_channel
muted_strips = []
try:
if target_channel != "ALL":
target_ch_int = int(target_channel)
for strip in context.scene.sequence_editor.strips_all:
if strip.type == "SOUND" and not strip.mute:
if strip.channel != target_ch_int:
strip.mute = True
muted_strips.append(strip)
file_path = extract_audio()
finally:
# Restore mute state
for strip in muted_strips:
strip.mute = False
if not os.path.isfile(f"{file_path}"):
self.report(
@@ -90,15 +109,20 @@ class LIPSYNC2D_OT_AnalyzeAudio(bpy.types.Operator):
phonemes = LIPSYNC2D_DialogInspector.extract_phonemes(words, context)
auto_obj = self.get_animator(obj)
props = obj.lipsync2d_props # type: ignore
debug_entries = [] if props.lip_sync_2d_debug_output else None
auto_obj.setup(obj)
self.auto_insert_keyframes(
auto_obj, obj, recognized_words, dialog_inspector, total_words, phonemes
auto_obj, obj, recognized_words, dialog_inspector, total_words, phonemes, debug_entries
)
auto_obj.set_interpolation(obj)
auto_obj.cleanup(obj)
self.reset_bake_range()
if debug_entries is not None:
self.write_debug_output(debug_entries)
if bpy.context.view_layer:
bpy.context.view_layer.update()
@@ -116,6 +140,7 @@ class LIPSYNC2D_OT_AnalyzeAudio(bpy.types.Operator):
dialog_inspector: LIPSYNC2D_DialogInspector,
total_words,
phonemes,
debug_entries: list | None = None,
):
props = obj.lipsync2d_props # type: ignore
words = enumerate(recognized_words)
@@ -123,9 +148,19 @@ class LIPSYNC2D_OT_AnalyzeAudio(bpy.types.Operator):
for index, recognized_word in words:
is_last_word = index == total_words - 1
word_timing = dialog_inspector.get_word_timing(recognized_word)
current_phonemes = phonemes[index]
visemes_data = dialog_inspector.get_visemes(
phonemes[index], word_timing["duration"]
current_phonemes, word_timing["duration"]
)
if debug_entries is not None:
debug_entries.append({
"word": recognized_word["word"],
"phonemes": current_phonemes,
"visemes": visemes_data,
"start": word_timing["word_frame_start"],
})
next_word_timing = dialog_inspector.get_next_word_timing(
recognized_words, index
)
@@ -151,6 +186,54 @@ class LIPSYNC2D_OT_AnalyzeAudio(bpy.types.Operator):
index,
)
def write_debug_output(self, entries):
text_name = "LipSync Debug"
text = bpy.data.texts.get(text_name)
if text is None:
text = bpy.data.texts.new(text_name)
else:
text.clear()
# Header
output = [
f"{'Word':<15} {'Start':<10} {'Phonemes':<15} {'Viseme':<10} {'Frame':<10}",
"-" * 60
]
for entry in entries:
word = entry['word']
start_frame = entry['start']
phonemes = entry['phonemes'] # list of phonemes strings
phonemes_str = " ".join(phonemes)
viseme_data = entry['visemes']
visemes_list = viseme_data['visemes']
part_duration = viseme_data['visemes_parts']
# First line with word info
first_viseme = visemes_list[0] if visemes_list else ""
first_viseme_frame = f"{start_frame:.2f}"
# If no visemes, just print word info
if not visemes_list:
output.append(f"{word:<15} {start_frame:<10} {phonemes_str:<15}")
continue
# Print first viseme with word info
output.append(f"{word:<15} {start_frame:<10} {phonemes_str:<15} {visemes_list[0]:<10} {first_viseme_frame:<10}")
# Print remaining visemes
current_frame = start_frame
for i in range(1, len(visemes_list)):
current_frame += part_duration
viseme = visemes_list[i]
output.append(f"{'':<15} {'':<10} {'':<15} {viseme:<10} {current_frame:.2f}")
# Add a separator blank line or just spacing
# output.append("")
text.write("\n".join(output))
@staticmethod
def get_animator(obj: BpyObject) -> LIPSYNC2D_LipSyncAnimator:
props = obj.lipsync2d_props # type: ignore
@@ -95,6 +95,8 @@ class AnimatorPanelMixin:
row = panel_body.row()
row.prop(self.props, "lip_sync_2d_use_clear_keyframes")
row = panel_body.row()
row.prop(self.props, "lip_sync_2d_debug_output")
row = panel_body.row()
row.label(text="Range:")
row = panel_body.row()
row.prop(self.props, "lip_sync_2d_use_bake_range")
@@ -102,3 +104,6 @@ class AnimatorPanelMixin:
row.prop(self.props, "lip_sync_2d_bake_start", text="Start")
row.prop(self.props, "lip_sync_2d_bake_end", text="End")
row.enabled = self.props.lip_sync_2d_use_bake_range # type: ignore
row = panel_body.row()
row.prop(self.props, "lip_sync_2d_bake_channel")
@@ -154,47 +154,93 @@ def get_lip_sync_type_items(self, context: BpyContext | None):
def poll_pose_assets(self, obj: bpy.types.ID):
return bool(obj.asset_data)
# Ensure it's an Action
if not isinstance(obj, bpy.types.Action):
return False
# Must have asset data (be marked as a pose asset)
if not obj.asset_data:
return False
# Allow local, linked, and library override actions
# This is necessary because linked library pose assets should still be usable
return True
def get_channel_items(self, context: BpyContext | None):
items = [("ALL", "All Channels", "Bake audio from all channels")]
if context is None or context.scene is None or context.scene.sequence_editor is None:
return intern_enum_items(items)
channels = set()
for strip in context.scene.sequence_editor.strips_all:
if strip.type == "SOUND" and not strip.mute:
channels.add(strip.channel)
sorted_channels = sorted(list(channels))
for channel in sorted_channels:
c_data = context.scene.sequence_editor.channels[channel]
channel_name = str(channel)+" - "+c_data.name
if c_data.mute:
channel_name += " (Muted)"
items.append((str(channel), channel_name, f"Bake audio from Channel {channel}"))
return intern_enum_items(items)
class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
lip_sync_2d_bake_channel: bpy.props.EnumProperty(
name="Channel",
description="Select specific channel to bake audio from",
items=get_channel_items
) # type: ignore
lip_sync_2d_initialized: bpy.props.BoolProperty(
name="Initilize Lip Sync",
description="Initilize Lip Sync on selection",
default=False,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sprite_sheet: bpy.props.PointerProperty(
name="Sprite Sheet",
description="The name of the addon to reload",
type=bpy.types.Image,
update=update_sprite_sheet,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_main_material: bpy.props.PointerProperty(
name="Main Material",
description="Material containing Sprite sheet",
type=bpy.types.Material,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sprite_sheet_columns: bpy.props.IntProperty(
name="Columns", description="Total of columns in sprite sheet", default=1
name="Columns", description="Total of columns in sprite sheet", default=1,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sprite_sheet_rows: bpy.props.IntProperty(
name="Rows",
description="Total of rows in sprite sheet",
update=update_sprite_sheet_rows,
default=1,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sprite_sheet_sprite_scale: bpy.props.FloatProperty(
name="Sprite",
description="Adjust sprite scale so it fits in mouth area",
default=1,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sprite_sheet_main_scale: bpy.props.FloatProperty(
name="Lips", description="Adjust Lips scale", default=1
name="Lips", description="Adjust Lips scale", default=1,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sprite_sheet_index: bpy.props.IntProperty(
name="Sprite Index",
description="Sprite Index. Start at 0, from Bottom Left to Top Right",
default=1,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sprite_sheet_format: bpy.props.EnumProperty(
name="Sprite sheet format",
@@ -215,6 +261,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
],
update=update_sprite_sheet_format,
default=3,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_lips_type: bpy.props.EnumProperty(
@@ -223,6 +270,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
items=get_lip_sync_type_items,
update=update_sprite_sheet_format,
default=0,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_in_between_threshold: bpy.props.FloatProperty(
@@ -231,6 +279,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
default=0.0417,
subtype="TIME",
unit="TIME_ABSOLUTE",
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sil_threshold: bpy.props.FloatProperty(
@@ -239,6 +288,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
default=0.22,
subtype="TIME",
unit="TIME_ABSOLUTE",
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sps_in_between_threshold: bpy.props.FloatProperty(
@@ -247,6 +297,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
default=0.0417,
subtype="TIME",
unit="TIME_ABSOLUTE",
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sps_sil_threshold: bpy.props.FloatProperty(
@@ -255,6 +306,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
default=0.22,
subtype="TIME",
unit="TIME_ABSOLUTE",
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_close_motion_duration: bpy.props.FloatProperty(
@@ -263,30 +315,41 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
default=0.2,
subtype="TIME",
unit="TIME_ABSOLUTE",
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_remove_animation_data: bpy.props.BoolProperty(
name="Remove Animation",
description="Also remove action, action slot and keyframes",
default=True,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_remove_cgp_node_group: bpy.props.BoolProperty(
name="Remove Nodes",
description="Also remove node groups from Object's Materials",
default=True,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_use_clear_keyframes: bpy.props.BoolProperty(
name="Clear Keyframes",
description="Clear Keyframes before Bake",
default=True,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_use_bake_range: bpy.props.BoolProperty(
name="Use Range",
description="Only bake between specified range",
default=False,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_debug_output: bpy.props.BoolProperty(
name="Debug Output",
description="Output phoneme and viseme data to a text block",
default=False,
) # type: ignore
lip_sync_2d_bake_start: bpy.props.IntProperty(
@@ -296,6 +359,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
min=0,
set=set_bake_start,
get=get_bake_start,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_bake_end: bpy.props.IntProperty(
@@ -305,6 +369,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
min=0,
set=set_bake_end,
get=get_bake_end,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_rig_type_basic: bpy.props.BoolProperty(
@@ -315,6 +380,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
),
default=True,
update=update_rig_type_basic,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_rig_type_advanced: bpy.props.BoolProperty(
@@ -325,6 +391,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
"Only use this if Basic Rig is not working."
),
update=update_rig_type_advanced,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_prioritize_accuracy: bpy.props.BoolProperty(
@@ -335,6 +402,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
"from being skipped when they occur in rapid succession."
),
default=False,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
@classmethod
@@ -373,5 +441,6 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
name=f"Viseme {name}",
description=desc,
poll=poll_pose_assets,
),
override={'LIBRARY_OVERRIDABLE'}
), # type: ignore
)
@@ -38,7 +38,10 @@ def register():
bpy.utils.register_class(LIPSYNC2D_PT_Edit)
bpy.utils.register_class(LIPSYNC2D_OT_RemoveAnimations)
bpy.utils.register_class(LIPSYNC2D_OT_refresh_pose_assets)
bpy.types.Object.lipsync2d_props = bpy.props.PointerProperty(type=LIPSYNC2D_PG_CustomProperties) # type: ignore
bpy.types.Object.lipsync2d_props = bpy.props.PointerProperty(
type=LIPSYNC2D_PG_CustomProperties,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
def unregister():
@@ -1,6 +1,6 @@
schema_version = "1.0.0"
id = "iocgpoly_lip_sync"
version = "2.3.2"
version = "1.0.6"
name = "Lip Sync"
tagline = "Automatic lip sync for your Blender models"
maintainer = "Charley 3D <charley@cgpoly.fr>"
@@ -30,11 +30,3 @@ paths_exclude_pattern = [
"/scripts/",
".releaserc"
]
# BEGIN GENERATED CONTENT.
# This must not be included in source manifests.
[build.generated]
platforms = ["windows-x64"]
wheels = ["./wheels/attrs-25.3.0-py3-none-any.whl", "./wheels/babel-2.17.0-py3-none-any.whl", "./wheels/cffi-1.17.1-cp311-cp311-win_amd64.whl", "./wheels/csvw-3.5.1-py2.py3-none-any.whl", "./wheels/dlinfo-2.0.0-py3-none-any.whl", "./wheels/isodate-0.7.2-py3-none-any.whl", "./wheels/joblib-1.4.2-py3-none-any.whl", "./wheels/jsonschema-4.23.0-py3-none-any.whl", "./wheels/jsonschema_specifications-2024.10.1-py3-none-any.whl", "./wheels/language_tags-1.2.0-py3-none-any.whl", "./wheels/phonemizer-3.3.0-py3-none-any.whl", "./wheels/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", "./wheels/rdflib-7.1.4-py3-none-any.whl", "./wheels/referencing-0.36.2-py3-none-any.whl", "./wheels/regex-2024.11.6-cp311-cp311-win_amd64.whl", "./wheels/rfc3986-1.5.0-py2.py3-none-any.whl", "./wheels/rpds_py-0.24.0-cp311-cp311-win_amd64.whl", "./wheels/segments-2.3.0-py2.py3-none-any.whl", "./wheels/six-1.17.0-py2.py3-none-any.whl", "./wheels/tqdm-4.67.1-py3-none-any.whl", "./wheels/typing_extensions-4.13.2-py3-none-any.whl", "./wheels/uritemplate-4.1.1-py2.py3-none-any.whl", "./wheels/vosk-0.3.41-py3-none-win_amd64.whl"]
# END GENERATED CONTENT.
@@ -0,0 +1,169 @@
import os
import shutil
import zipfile
from collections import defaultdict
from glob import glob
import tomlkit
def update_wheels():
folder = "./wheels"
files = os.listdir(folder)
toml_path = "blender_manifest.toml"
wheel_paths = glob("./wheels/**/*.whl", recursive=True)
print(wheel_paths)
clean = [path.replace("\\", "/") for path in wheel_paths]
# Load the TOML file
with open(toml_path, "r", encoding="utf-8") as f:
toml_data = tomlkit.load(f)
# Update the "wheels" entry
toml_data["wheels"] = clean
# Write back to the TOML file, preserving formatting and comments
with open(toml_path, "w", encoding="utf-8") as f:
f.write(tomlkit.dumps(toml_data))
def build_addon():
current_dir = os.getcwd()
exclude_patterns = [
'.venv',
'.gitignore',
'.git',
'.idea',
'__pycache__',
'dist',
'dev_tools.py'
]
dist_dir = os.path.join(current_dir, "dist")
if not os.path.exists(dist_dir):
os.makedirs(dist_dir)
with open("blender_manifest.toml", "r") as f:
parsed = tomlkit.parse(f.read())
version = parsed["version"]
addon_id = parsed["id"]
with zipfile.ZipFile(f"{current_dir}/dist/{addon_id}_{version}.zip", "w") as zip_file:
for root, dirs, files in os.walk(f"{current_dir}"):
dirs[:] = [d for d in dirs if os.path.relpath(os.path.join(root,d), current_dir) not in exclude_patterns]
for file in files:
file_path = os.path.relpath(os.path.join(root, file), current_dir)
# Skip files based on exclude patterns
if any(file_path.startswith(pattern) or file_path in exclude_patterns for pattern in exclude_patterns):
continue
# Add the file to the zip archive
zip_file.write(os.path.join(root, file), file_path)
print(f"Build complete. Addon file saved to {dist_dir}")
def handle_duplicate_wheels(directory: str):
"""
Find and handle duplicate .whl files in the given directory and its subdirectories.
Keeps files in ./wheels/common and removes duplicates. If a file is not in ./wheels/common,
moves one to ./wheels/common and deletes the others.
:param directory: str: The path to the directory to search for .whl files.
:return: None
"""
# Path to the common directory
common_path = os.path.join(directory, "common")
# Create the common directory if it doesn't exist
os.makedirs(common_path, exist_ok=True)
# Dictionary to store .whl filenames and their paths
files_dict = defaultdict(list)
# Traverse the directory and its subdirectories
for root, _, files in os.walk(directory):
for file in files:
print(f"Processing file: {file}")
# Check if the file is a .whl file
if file.endswith(".whl"):
# Store the file and its full path in the dictionary
full_path = os.path.join(root, file)
files_dict[file].append(full_path)
# Handle duplicates (files with more than one associated path)
for file, paths in files_dict.items():
if len(paths) > 1: # Check if there are duplicates
print(f"\nDuplicate found for: {file}")
print("Locations:")
for path in paths:
print(f" - {path}")
# Check if the file already exists in the common directory
common_file_path = os.path.join(common_path, file)
if os.path.exists(common_file_path):
# Remove all duplicates, as the file already exists in common
print(f"File already in common directory: {common_file_path}")
for path in paths:
if path != common_file_path:
print(f"Removing duplicate: {path}")
os.remove(path)
else:
# Move one file to common and remove the others
print(f"Moving one copy to common directory: {common_file_path}")
shutil.move(paths[0], common_file_path) # Move the first file to common
for path in paths[1:]:
print(f"Removing duplicate: {path}")
os.remove(path)
else:
print(f"No duplicates for: {file}")
import hashlib
import os
def md5_for_folder(folder_path: str):
"""
Compute the MD5 hash of the contents of a folder including file names and contents.
:param folder_path: str: Path to the folder.
:return: str: The MD5 hash of the folder.
"""
md5_hash = hashlib.md5()
# Walk through the directory
for root, dirs, files in os.walk(folder_path):
# Sort directories and files to ensure consistent order (important for consistent hash values)
for names in sorted(dirs + files):
# Update hash with file/folder name
path = os.path.join(root, names)
md5_hash.update(names.encode('utf-8'))
# If it's a file, include its content in the hash
if os.path.isfile(path):
with open(path, 'rb') as f:
while chunk := f.read(8192): # Read file in chunks
md5_hash.update(chunk)
return md5_hash.hexdigest()
if __name__ == "__main__":
update_wheels()
# Example usage
# folder = "./Assets/Archives/darwin/espeak-ng-darwin/espeak-ng-data"
# print(f"MD5 Hash for the folder '{folder}': {md5_for_folder(folder)}")
# folder = "./Assets/Archives/linux/espeak-ng-data/lang"
# print(f"MD5 Hash for the folder '{folder}': {md5_for_folder(folder)}")
# folder = "./Assets/Archives/windows/espeak-ng-data/lang"
# print(f"MD5 Hash for the folder '{folder}': {md5_for_folder(folder)}")
# update_wheels()
@@ -0,0 +1,13 @@
import sys
from tomlkit import parse, dumps # type: ignore
version = sys.argv[1]
toml_path = "blender_manifest.toml"
with open(toml_path, "r", encoding="utf-8") as f:
doc = parse(f.read())
doc["version"] = version
with open(toml_path, "w", encoding="utf-8") as f:
f.write(dumps(doc))
@@ -1,62 +1,41 @@
import bpy
"""
Remove Static FCurves - Blender Extension
Removes animation channels with static (unchanging) values
"""
bl_info = {
"name": "Remove Static FCurves",
"author": "lokimckay",
"version": (0, 3, 0),
"blender": (5, 0, 0),
"location": "Graph Editor > Channel > Remove Static FCurves",
"description": "Remove animation channels with static (unchanging) values",
"category": "Animation",
}
class RemoveStaticFcurvesOperator(bpy.types.Operator):
"""Operator to remove static FCurves"""
bl_idname = "graph.remove_static_fcurves"
bl_label = "Remove Static FCurves"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
has_selection = any(obj.select_get()
for obj in bpy.context.selected_objects)
if not has_selection:
self.report({'ERROR_INVALID_INPUT'}, "Please select objects.")
else:
self.remove_static_fcurves()
self.report({'INFO'}, "Removed static animation channels.")
return {'FINISHED'}
@staticmethod
def is_static_fcurve(fcurve):
"""Check if an FCurve is static (all keyframes have the same value)."""
keyframes = fcurve.keyframe_points
if len(keyframes) < 2:
return True # A single keyframe is considered static
first_value = keyframes[0].co[1]
return all(kf.co[1] == first_value for kf in keyframes)
@staticmethod
def remove_static_fcurves():
"""Remove static FCurves that have no data."""
for obj in bpy.context.selected_objects:
if obj.animation_data and obj.animation_data.action:
action = obj.animation_data.action
fcurves_to_remove = [
fcurve for fcurve in action.fcurves if RemoveStaticFcurvesOperator.is_static_fcurve(fcurve)]
for fcurve in fcurves_to_remove:
action.fcurves.remove(fcurve)
def menu_func(self, context):
self.layout.operator(RemoveStaticFcurvesOperator.bl_idname)
# Handle module reloading
if "bpy" in locals():
import importlib
if "operator" in locals():
importlib.reload(operator)
if "utils" in locals():
importlib.reload(utils)
print("[Remove Static FCurves] Reloading modules...")
else:
from . import operator
from . import utils
def register():
bpy.utils.register_class(RemoveStaticFcurvesOperator)
bpy.types.GRAPH_MT_channel.append(menu_func)
bpy.types.DOPESHEET_MT_channel.append(menu_func)
print("[Remove Static FCurves] registered")
"""Register all classes and handlers"""
operator.register()
print("[Remove Static FCurves] Registered")
def unregister():
bpy.utils.unregister_class(RemoveStaticFcurvesOperator)
bpy.types.GRAPH_MT_channel.remove(menu_func)
bpy.types.DOPESHEET_MT_channel.remove(menu_func)
print("[Remove Static FCurves] unregistered")
"""Unregister all classes and handlers"""
operator.unregister()
print("[Remove Static FCurves] Unregistered")
if __name__ == "__main__":
@@ -3,7 +3,7 @@ schema_version = "1.0.0"
# Example of manifest file for a Blender extension
# Change the values according to your extension
id = "remove_static_fcurves"
version = "0.2.1"
version = "0.4.0"
name = "Remove Static FCurves"
tagline = "Clean up animation channels that have no data"
maintainer = "Loki McKay <lokimckay@gmail.com>"
@@ -24,9 +24,7 @@ blender_version_min = "4.2.0"
# License conforming to https://spdx.org/licenses/ (use "SPDX: prefix)
# https://docs.blender.org/manual/en/dev/advanced/extensions/licenses.html
license = [
"SPDX:GPL-3.0-or-later",
]
license = ["SPDX:GPL-3.0-or-later"]
# # Optional: required by some licenses.
# copyright = [
# "2002-2024 Developer Name",
@@ -72,4 +70,4 @@ license = [
# "__pycache__/",
# "/.git/",
# "/*.zip",
# ]
# ]
@@ -0,0 +1,129 @@
"""
Operators for removing static FCurves
"""
import bpy
from . import utils
class RemoveStaticFcurvesOperator(bpy.types.Operator):
"""Operator to remove static FCurves"""
bl_idname = "graph.remove_static_fcurves"
bl_label = "Remove Static FCurves"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
# Get objects with animation data
objects_to_check = set()
# Add selected objects
objects_to_check.update(
obj for obj in context.selected_objects if obj.animation_data)
# Add visible objects from Graph Editor if we're in that context
if context.area and context.area.type == 'GRAPH_EDITOR':
for obj in context.scene.objects:
if obj.animation_data and obj.animation_data.action:
objects_to_check.add(obj)
# Also check the active object specifically
if context.active_object and context.active_object.animation_data:
objects_to_check.add(context.active_object)
if not objects_to_check:
self.report({'WARNING'}, "No objects with animation data found.")
return {'CANCELLED'}
print(f"\n=== Remove Static FCurves ===")
print(f"Checking {len(objects_to_check)} object(s)")
removed_count = self.remove_static_fcurves(objects_to_check)
if removed_count > 0:
self.report(
{'INFO'}, f"Removed {removed_count} static animation channel(s).")
else:
self.report({'INFO'}, "No static FCurves found to remove.")
return {'FINISHED'}
@staticmethod
def is_static_fcurve(fcurve):
# If the curve has modifiers, keep it
if fcurve.modifiers and len(fcurve.modifiers) > 0:
return False
"""Check if an FCurve is static (all keyframes have the same value)."""
keyframes = fcurve.keyframe_points
if len(keyframes) < 2:
return True # A single keyframe is considered static
first_value = keyframes[0].co[1]
tolerance = 0.0001 # Small tolerance for floating point comparison
return all(abs(kf.co[1] - first_value) < tolerance for kf in keyframes)
@staticmethod
def remove_static_fcurves(objects):
"""Remove static FCurves from the given objects."""
removed_count = 0
for obj in objects:
if not obj.animation_data or not obj.animation_data.action:
continue
print(f"\nProcessing object: {obj.name}")
action = obj.animation_data.action
print(f" Action: {action.name}")
# Get FCurves using the compatibility function
fcurves_data = utils.compat.get_fcurves_from_object(obj)
if not fcurves_data:
print(f" No FCurves found")
continue
print(f" Found {len(fcurves_data)} FCurve(s)")
# Check each FCurve
for action, fcurve, slot in fcurves_data:
is_static = RemoveStaticFcurvesOperator.is_static_fcurve(
fcurve)
if is_static:
data_path = fcurve.data_path
array_idx = fcurve.array_index
print(
f" Removing static FCurve: {data_path}[{array_idx}]")
# Remove using the compatibility function
if utils.compat.remove_action_fcurve(action, fcurve, slot):
removed_count += 1
else:
print(f" Failed to remove FCurve")
else:
print(
f" Keeping animated FCurve: {fcurve.data_path}[{fcurve.array_index}]")
return removed_count
def menu_func(self, context):
self.layout.operator(RemoveStaticFcurvesOperator.bl_idname)
def register():
bpy.utils.register_class(RemoveStaticFcurvesOperator)
bpy.types.GRAPH_MT_channel.append(menu_func)
bpy.types.DOPESHEET_MT_channel.append(menu_func)
print("[RemoveStaticFcurvesOperator] registered")
def unregister():
bpy.utils.unregister_class(RemoveStaticFcurvesOperator)
bpy.types.GRAPH_MT_channel.remove(menu_func)
bpy.types.DOPESHEET_MT_channel.remove(menu_func)
print("[RemoveStaticFcurvesOperator] unregistered")
if __name__ == "__main__":
register()
@@ -0,0 +1,14 @@
Metadata-Version: 2.4
Name: remove-static-fcurves
Version: 0.1.0
Summary: Remove fcurves from Blender animations that don't change at all.
Author-email: lokimckay <lokimckay@gmail.com>
License: MIT
Requires-Python: ==3.10.*
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file
# Remove Static FCurves
Cleans up animation channels that don't contain any animation data
@@ -0,0 +1,12 @@
LICENSE
README.md
pyproject.toml
src/__init__.py
src/operator.py
src/remove_static_fcurves.egg-info/PKG-INFO
src/remove_static_fcurves.egg-info/SOURCES.txt
src/remove_static_fcurves.egg-info/dependency_links.txt
src/remove_static_fcurves.egg-info/top_level.txt
src/utils/__init__.py
src/utils/compat.py
src/utils/version.py
@@ -0,0 +1,3 @@
__init__
operator
utils
@@ -0,0 +1,18 @@
"""
Utility modules for Remove Static FCurves extension
"""
# Handle module reloading
if "bpy" in locals():
import importlib
if "compat" in locals():
importlib.reload(compat)
if "version" in locals():
importlib.reload(version)
else:
from . import compat
from . import version
import bpy
__all__ = ["compat", "version"]
@@ -0,0 +1,144 @@
"""
API compatibility functions for handling differences between Blender versions.
"""
import bpy
from bpy_extras import anim_utils
from . import version
def get_action_fcurves(action):
"""
Get the fcurves collection from an action, handling version differences.
In Blender 4.x: action.fcurves
In Blender 5.0+: Uses channelbag.fcurves via action slots
Args:
action: The action object
Returns:
Collection of fcurves, or None if not available
"""
if version.is_version_at_least(5, 0, 0):
# Blender 5.0+ uses channelbags and slots
# Try to get fcurves from all slots in the action
all_fcurves = []
# Iterate through all slots in the action
if hasattr(action, 'slots') and action.slots:
for slot in action.slots:
try:
channelbag = anim_utils.action_get_channelbag_for_slot(
action, slot)
if channelbag and hasattr(channelbag, 'fcurves'):
all_fcurves.extend(channelbag.fcurves)
except (AttributeError, RuntimeError):
continue
return all_fcurves if all_fcurves else None
else:
# Blender 4.x uses direct fcurves attribute
if hasattr(action, 'fcurves'):
return action.fcurves
return None
def get_fcurves_from_object(obj):
"""
Get FCurves from an object's animation data, handling version differences.
Args:
obj: The object with animation data
Returns:
List of (action, fcurve, slot) tuples, or empty list
"""
if not obj.animation_data or not obj.animation_data.action:
return []
action = obj.animation_data.action
fcurves_list = []
if version.is_version_at_least(5, 0, 0):
# Blender 5.0+: Get the channelbag for the object's current action slot
if hasattr(obj.animation_data, 'action_slot') and obj.animation_data.action_slot:
try:
channelbag = anim_utils.action_get_channelbag_for_slot(
action,
obj.animation_data.action_slot
)
if channelbag and hasattr(channelbag, 'fcurves'):
for fcurve in channelbag.fcurves:
fcurves_list.append(
(action, fcurve, obj.animation_data.action_slot))
except (AttributeError, RuntimeError) as e:
print(f"Error getting channelbag for {obj.name}: {e}")
else:
# Fallback: try all slots if no specific slot is set
if hasattr(action, 'slots') and action.slots:
for slot in action.slots:
try:
channelbag = anim_utils.action_get_channelbag_for_slot(
action, slot)
if channelbag and hasattr(channelbag, 'fcurves'):
for fcurve in channelbag.fcurves:
fcurves_list.append((action, fcurve, slot))
except (AttributeError, RuntimeError):
continue
else:
# Blender 4.x: Direct access to fcurves
if hasattr(action, 'fcurves'):
for fcurve in action.fcurves:
fcurves_list.append((action, fcurve, None))
return fcurves_list
def remove_action_fcurve(action, fcurve, slot=None):
"""
Remove an fcurve from an action, handling version differences.
Args:
action: The action object
fcurve: The fcurve to remove
slot: (Blender 5.0+) The action slot containing the fcurve
Returns:
bool: True if removal succeeded, False otherwise
"""
if version.is_version_at_least(5, 0, 0):
# Blender 5.0+: Need to remove from the channelbag
if slot is None:
# Try to find which slot contains this fcurve
if hasattr(action, 'slots'):
for try_slot in action.slots:
try:
channelbag = anim_utils.action_get_channelbag_for_slot(
action, try_slot)
if channelbag and hasattr(channelbag, 'fcurves'):
if fcurve in channelbag.fcurves:
channelbag.fcurves.remove(fcurve)
return True
except (ValueError, AttributeError, RuntimeError):
continue
else:
# We know which slot to use
try:
channelbag = anim_utils.action_get_channelbag_for_slot(
action, slot)
if channelbag and hasattr(channelbag, 'fcurves'):
channelbag.fcurves.remove(fcurve)
return True
except (ValueError, AttributeError, RuntimeError) as e:
print(f"Error removing fcurve: {e}")
else:
# Blender 4.x: Direct removal
if hasattr(action, 'fcurves'):
try:
action.fcurves.remove(fcurve)
return True
except (ValueError, AttributeError):
pass
return False
@@ -0,0 +1,70 @@
"""
Version detection and comparison utilities for multi-version Blender support.
"""
import bpy
def get_blender_version():
"""
Returns the current Blender version as a tuple (major, minor, patch).
Returns:
tuple: (major, minor, patch) version numbers
"""
return bpy.app.version
def get_version_string():
"""
Returns the current Blender version as a string (e.g., "4.2.0").
Returns:
str: Version string in format "major.minor.patch"
"""
version = get_blender_version()
return f"{version[0]}.{version[1]}.{version[2]}"
def is_version_at_least(major, minor=0, patch=0):
"""
Check if the current Blender version is at least the specified version.
Args:
major (int): Major version number
minor (int): Minor version number (default: 0)
patch (int): Patch version number (default: 0)
Returns:
bool: True if current version >= specified version
"""
current = get_blender_version()
target = (major, minor, patch)
if current[0] != target[0]:
return current[0] > target[0]
if current[1] != target[1]:
return current[1] > target[1]
return current[2] >= target[2]
def is_version_less_than(major, minor=0, patch=0):
"""
Check if the current Blender version is less than the specified version.
Args:
major (int): Major version number
minor (int): Minor version number (default: 0)
patch (int): Patch version number (default: 0)
Returns:
bool: True if current version < specified version
"""
return not is_version_at_least(major, minor, patch)
def is_version_5_0():
"""Check if running Blender 5.0 or later."""
return is_version_at_least(5, 0, 0)