2026-02-16
This commit is contained in:
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.
|
||||
|
||||

|
||||
|
||||
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 platform’s 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 }}
|
||||
|
||||
+118
@@ -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
|
||||
|
||||
+36
@@ -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}" }
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+30
-1
@@ -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,
|
||||
|
||||
+41
-6
@@ -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")
|
||||
|
||||
+73
-4
@@ -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))
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
+12
@@ -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
|
||||
+1
@@ -0,0 +1 @@
|
||||
|
||||
+3
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user