update addons (for 5.2)
This commit is contained in:
@@ -2,12 +2,33 @@
|
||||
"version": "v1",
|
||||
"blocklist": [],
|
||||
"data": [
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "atomic_data_manager",
|
||||
"name": "Atomic Data Manager",
|
||||
"tagline": "Smart cleanup and inspection of Blender data-blocks",
|
||||
"version": "2.7.0",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon",
|
||||
"license": [
|
||||
"GPL-3.0-or-later"
|
||||
],
|
||||
"blender_version_min": "4.2.0",
|
||||
"tags": [
|
||||
"utility",
|
||||
"management",
|
||||
"cleanup"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/2.7.0/Atomic_Data_Manager.v2.7.0.zip",
|
||||
"archive_size": 129728,
|
||||
"archive_hash": "sha256:d0ab366ff9d6e9341a0670ca7f16e62d6bd9c68974bc2ce93306b56dfbfef028"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "basedplayblast",
|
||||
"name": "BasedPlayblast",
|
||||
"tagline": "Easily create playblasts from Blender and Flamenco",
|
||||
"version": "2.6.3",
|
||||
"version": "2.8.1",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
|
||||
"license": [
|
||||
@@ -24,9 +45,9 @@
|
||||
"Workflow",
|
||||
"Video"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.3/BasedPlayblast.v2.6.3.zip",
|
||||
"archive_size": 49732,
|
||||
"archive_hash": "sha256:078b406105ce6f4802e75233569841e2f73d082e09cd1d954696681ebf72b627"
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/2.8.1/BasedPlayblast.2.8.1.zip",
|
||||
"archive_size": 57862,
|
||||
"archive_hash": "sha256:ae8657532b2716f01cac077f1ed476d6495dbcad05a0f14286a48650fa06f8a8"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
@@ -53,27 +74,6 @@
|
||||
"archive_size": 80981,
|
||||
"archive_hash": "sha256:419433069465b45ea903bd7bb46d89aa28b9c96c541d587d5f3be651a762811f"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "atomic_data_manager",
|
||||
"name": "Atomic Data Manager",
|
||||
"tagline": "Smart cleanup and inspection of Blender data-blocks",
|
||||
"version": "2.6.3",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon",
|
||||
"license": [
|
||||
"GPL-3.0-or-later"
|
||||
],
|
||||
"blender_version_min": "4.2.0",
|
||||
"tags": [
|
||||
"utility",
|
||||
"management",
|
||||
"cleanup"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.6.3/Atomic_Data_Manager.v2.6.3.zip",
|
||||
"archive_size": 122700,
|
||||
"archive_hash": "sha256:b444463a0443864077abcfe97332406e606a697d3567ac32f85843760da11372"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "sheepit_project_submitter",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"blocklist": [],
|
||||
"data": [
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "atomic_data_manager",
|
||||
"name": "Atomic Data Manager",
|
||||
"tagline": "Smart cleanup and inspection of Blender data-blocks",
|
||||
"version": "2.7.0",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon",
|
||||
"license": [
|
||||
"GPL-3.0-or-later"
|
||||
],
|
||||
"blender_version_min": "4.2.0",
|
||||
"tags": [
|
||||
"utility",
|
||||
"management",
|
||||
"cleanup"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/2.7.0/Atomic_Data_Manager.v2.7.0.zip",
|
||||
"archive_size": 129728,
|
||||
"archive_hash": "sha256:d0ab366ff9d6e9341a0670ca7f16e62d6bd9c68974bc2ce93306b56dfbfef028"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "basedplayblast",
|
||||
"name": "BasedPlayblast",
|
||||
"tagline": "Easily create playblasts from Blender and Flamenco",
|
||||
"version": "2.8.1",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
|
||||
"license": [
|
||||
"GPL-3.0-or-later"
|
||||
],
|
||||
"blender_version_min": "4.2.0",
|
||||
"website": "https://github.com/RaincloudTheDragon/BasedPlayblast",
|
||||
"permissions": {
|
||||
"files": "Import/export files and data"
|
||||
},
|
||||
"tags": [
|
||||
"Animation",
|
||||
"Render",
|
||||
"Workflow",
|
||||
"Video"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/2.8.1/BasedPlayblast.2.8.1.zip",
|
||||
"archive_size": 57862,
|
||||
"archive_hash": "sha256:ae8657532b2716f01cac077f1ed476d6495dbcad05a0f14286a48650fa06f8a8"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "rainclouds_bulk_scene_tools",
|
||||
"name": "Raincloud's Bulk Scene Tools",
|
||||
"tagline": "Bulk utilities for optimizing scene data",
|
||||
"version": "0.17.0",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
|
||||
"license": [
|
||||
"GPL-3.0-or-later"
|
||||
],
|
||||
"blender_version_min": "4.2.0",
|
||||
"website": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
|
||||
"permissions": {
|
||||
"files": "Read and write external resources referenced by scenes"
|
||||
},
|
||||
"tags": [
|
||||
"Scene",
|
||||
"Workflow",
|
||||
"Materials"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.17.0/Rainys_Bulk_Scene_Tools.v0.17.0.zip",
|
||||
"archive_size": 80981,
|
||||
"archive_hash": "sha256:419433069465b45ea903bd7bb46d89aa28b9c96c541d587d5f3be651a762811f"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "sheepit_project_submitter",
|
||||
"name": "SheepIt Project Submitter",
|
||||
"tagline": "Submit projects to SheepIt render farm",
|
||||
"version": "0.0.8",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon",
|
||||
"license": [
|
||||
"GPL-3.0-or-later"
|
||||
],
|
||||
"blender_version_min": "3.0.0",
|
||||
"tags": [
|
||||
"render",
|
||||
"farm",
|
||||
"submission",
|
||||
"utility"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/sheepit_project_submitter/releases/download/v0.0.8/SheepIt_Project_Submitter.v0.0.8.zip",
|
||||
"archive_size": 47667,
|
||||
"archive_hash": "sha256:93cd8f18456079130c48c66cfd40235f7fe6414f929f59f90670e7a864821110"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,21 @@
|
||||
## [v2.7.0] - 2026-07-10
|
||||
|
||||
### Features
|
||||
|
||||
- **Stats → Storage (#17)**: index **in-blend caches** — packed **GeoNodes bakes** (exact `bake_size` via DNA) and **physics point caches** (cloth, soft body, particles, rigid body, dynamic paint) when baked without disk/external storage; Mantaflow/fluid disk caches remain out of scope.
|
||||
- **Stats → Storage (#18)**: **click a row name** to find scene users and select/activate the object; materials open the Shader Editor, actions open the Dope Sheet. Multiple users show a picker popup.
|
||||
- **Preferences**: **Frame View on Storage Navigate** (off by default) — optionally frame the user in the 3D viewport after clicking a storage row.
|
||||
- **Storage row icons**: GeoNodes bakes show **geometry-nodes** then **package** (packed) icons to distinguish baked data from node groups.
|
||||
|
||||
### Internal
|
||||
|
||||
- **Dev fixtures**: `testdata/` storage test `.blend` files (cloth, dynpaint, GeoNodes bakes, Mantaflow, particles, rigid body, soft body).
|
||||
- **Release / LFS**: track `*.blend` via Git LFS; release workflow sparse-checkout and zip guard exclude `testdata/` from shipped builds.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **README**: **Stats → Storage** section documents scope, icons, navigation, and exclusions (panel help text removed).
|
||||
|
||||
## [v2.6.3] - 2026-04-22
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -27,6 +27,20 @@ Atomic Data Manager offers Blender artists an intelligent data management soluti
|
||||
Pie Menu Controls, Advanced Fake User Options, Mass Delete Categories, Undo Accidental Deletions, Save Data-Blocks, Delete Data-Blocks, Replace Data-Blocks, Rename Data-Blocks, Reload Missing Files, Remove Missing Files, Replace Missing Files, and Search for Missing Files.
|
||||
|
||||
|
||||
## Stats → Storage
|
||||
|
||||
**Stats for Nerds → Storage** lists local datablocks ranked by estimated footprint inside the current `.blend`. Library-linked IDs from other files are excluded.
|
||||
|
||||
| What | Notes |
|
||||
|------|--------|
|
||||
| **Packed total** | Exact bytes embedded in the file (packed images, fonts, sounds, GeoNodes bakes). |
|
||||
| **Row sizes** | Most types are estimates; disk compression may differ. Physics point caches (cloth, soft body, particles, rigid body, dynamic paint) are in-blend estimates when baked with disk cache off. |
|
||||
| **GeoNodes bakes** | Packed bake data, not the node group. Icons: geometry nodes, then package (packed). |
|
||||
| **Mantaflow / fluid** | Disk-only caches are not indexed. |
|
||||
| **Icons** | Type icon(s), then library-override emblem when applicable. |
|
||||
| **Click a row name** | Selects and activates a scene user (material → Shader Editor, action → Dope Sheet). Optional **Frame View on Storage Navigate** in add-on preferences. |
|
||||
|
||||
|
||||
## TAKE A VIDEO TOUR
|
||||
[](https://remington.pro/software/blender/atomic/#tour)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ schema_version = "1.0.0"
|
||||
|
||||
id = "atomic_data_manager"
|
||||
name = "Atomic Data Manager"
|
||||
version = "2.6.3"
|
||||
version = "2.7.0"
|
||||
type = "add-on"
|
||||
author = "RaincloudTheDragon"
|
||||
maintainer = "RaincloudTheDragon"
|
||||
|
||||
@@ -33,6 +33,7 @@ enable_missing_file_warning = True
|
||||
include_fake_users = False
|
||||
enable_pie_menu_ui = True
|
||||
enable_debug_prints = False
|
||||
storage_navigate_frame_view = False
|
||||
|
||||
# hidden atomic preferences
|
||||
pie_menu_type = "D"
|
||||
|
||||
@@ -26,6 +26,7 @@ from . import main_ops
|
||||
from . import inspect_ops
|
||||
from . import direct_use_ops
|
||||
from . import missing_file_ops
|
||||
from . import storage_navigate_ops
|
||||
|
||||
|
||||
def register():
|
||||
@@ -33,10 +34,12 @@ def register():
|
||||
inspect_ops.register()
|
||||
direct_use_ops.register()
|
||||
missing_file_ops.register()
|
||||
storage_navigate_ops.register()
|
||||
|
||||
|
||||
def unregister():
|
||||
main_ops.unregister()
|
||||
inspect_ops.unregister()
|
||||
direct_use_ops.unregister()
|
||||
missing_file_ops.unregister()
|
||||
missing_file_ops.unregister()
|
||||
storage_navigate_ops.unregister()
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Operators for navigating from Atomic Storage rows to datablock users.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
from bpy.props import StringProperty
|
||||
from bpy.utils import register_class
|
||||
from ..utils import compat
|
||||
from ..utils import storage_nav
|
||||
|
||||
|
||||
class ATOMIC_OT_storage_navigate_pick(bpy.types.Operator):
|
||||
"""Navigate to a specific object from a storage row."""
|
||||
|
||||
bl_idname = "atomic.storage_navigate_pick"
|
||||
bl_label = "Show User"
|
||||
bl_options = {"INTERNAL"}
|
||||
|
||||
object_name: StringProperty(name="Object")
|
||||
material_name: StringProperty(name="Material", default="")
|
||||
action_name: StringProperty(name="Action", default="")
|
||||
nav_mode: StringProperty(name="Navigation", default="viewport")
|
||||
|
||||
def execute(self, context):
|
||||
target = {
|
||||
"object": self.object_name,
|
||||
"material": self.material_name,
|
||||
"action": self.action_name,
|
||||
"nav": self.nav_mode,
|
||||
}
|
||||
if storage_nav.apply_target(context, target):
|
||||
return {"FINISHED"}
|
||||
self.report({"WARNING"}, "Could not select '%s' in the current view layer" % self.object_name)
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class ATOMIC_OT_storage_navigate(bpy.types.Operator):
|
||||
"""Find and show users of a storage-index datablock."""
|
||||
|
||||
bl_idname = "atomic.storage_navigate"
|
||||
bl_label = "Show Storage User"
|
||||
bl_description = "Select and activate user object(s) from a storage row"
|
||||
|
||||
storage_type: StringProperty(name="Storage Type")
|
||||
id_name: StringProperty(name="ID Name")
|
||||
owner_object: StringProperty(name="Owner Object", default="")
|
||||
owner_scene: StringProperty(name="Owner Scene", default="")
|
||||
modifier_name: StringProperty(name="Modifier", default="")
|
||||
|
||||
def invoke(self, context, event):
|
||||
self._targets = storage_nav.resolve_targets(
|
||||
self.storage_type,
|
||||
self.id_name,
|
||||
owner_object=self.owner_object,
|
||||
owner_scene=self.owner_scene,
|
||||
modifier_name=self.modifier_name,
|
||||
)
|
||||
if not self._targets:
|
||||
self.report({"WARNING"}, "No scene users found for '%s'" % self.id_name)
|
||||
return {"CANCELLED"}
|
||||
if len(self._targets) == 1:
|
||||
return self.execute(context)
|
||||
return context.window_manager.invoke_popup(self, width=240)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.label(text="Select user:")
|
||||
for target in self._targets:
|
||||
op = layout.operator(
|
||||
"atomic.storage_navigate_pick",
|
||||
text=target["object"],
|
||||
icon="OBJECT_DATA",
|
||||
)
|
||||
op.object_name = target["object"]
|
||||
op.material_name = target.get("material", "")
|
||||
op.action_name = target.get("action", "")
|
||||
op.nav_mode = target.get("nav", "viewport")
|
||||
|
||||
def execute(self, context):
|
||||
targets = getattr(self, "_targets", None)
|
||||
if targets is None:
|
||||
targets = storage_nav.resolve_targets(
|
||||
self.storage_type,
|
||||
self.id_name,
|
||||
owner_object=self.owner_object,
|
||||
owner_scene=self.owner_scene,
|
||||
modifier_name=self.modifier_name,
|
||||
)
|
||||
if not targets:
|
||||
self.report({"WARNING"}, "No scene users found for '%s'" % self.id_name)
|
||||
return {"CANCELLED"}
|
||||
if storage_nav.apply_target(context, targets[0]):
|
||||
if len(targets) > 1:
|
||||
self.report({"INFO"}, "Showing first of %d users" % len(targets))
|
||||
return {"FINISHED"}
|
||||
self.report({"WARNING"}, "Could not select user in the current view layer")
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
reg_list = (
|
||||
ATOMIC_OT_storage_navigate,
|
||||
ATOMIC_OT_storage_navigate_pick,
|
||||
)
|
||||
|
||||
|
||||
def register():
|
||||
for item in reg_list:
|
||||
register_class(item)
|
||||
|
||||
|
||||
def unregister():
|
||||
for item in reg_list:
|
||||
compat.safe_unregister_class(item)
|
||||
@@ -1564,6 +1564,105 @@ def armature_all(armature_key):
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def _obdata_viewport_objects(obdata_key, obj_type=None):
|
||||
"""Objects in a scene whose data block matches obdata_key."""
|
||||
users = []
|
||||
for obj in bpy.data.objects:
|
||||
if compat.is_library_or_override(obj):
|
||||
continue
|
||||
data = getattr(obj, "data", None)
|
||||
if data is None or getattr(data, "name", None) != obdata_key:
|
||||
continue
|
||||
if obj_type and obj.type != obj_type:
|
||||
continue
|
||||
if object_all(obj.name):
|
||||
users.append(obj.name)
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def mesh_objects(mesh_key):
|
||||
return _obdata_viewport_objects(mesh_key, "MESH")
|
||||
|
||||
|
||||
def curve_objects(curve_key):
|
||||
return _obdata_viewport_objects(curve_key, "CURVE")
|
||||
|
||||
|
||||
def volume_objects(volume_key):
|
||||
return _obdata_viewport_objects(volume_key, "VOLUME")
|
||||
|
||||
|
||||
def pointcloud_objects(pointcloud_key):
|
||||
return _obdata_viewport_objects(pointcloud_key, "POINTCLOUD")
|
||||
|
||||
|
||||
def action_objects(action_key):
|
||||
users = []
|
||||
try:
|
||||
action = bpy.data.actions[action_key]
|
||||
except (KeyError, AttributeError):
|
||||
return []
|
||||
for obj in bpy.data.objects:
|
||||
if compat.is_library_or_override(obj):
|
||||
continue
|
||||
ad = getattr(obj, "animation_data", None)
|
||||
if ad and ad.action == action and object_all(obj.name):
|
||||
users.append(obj.name)
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def image_viewport_objects(image_key):
|
||||
users = []
|
||||
users.extend(image_geometry_nodes(image_key))
|
||||
for mat_name in image_materials(image_key):
|
||||
users.extend(material_objects(mat_name))
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def node_group_viewport_objects(node_group_key):
|
||||
users = []
|
||||
users.extend(node_group_objects(node_group_key))
|
||||
for mat_name in node_group_materials(node_group_key):
|
||||
users.extend(material_objects(mat_name))
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def collection_viewport_objects(collection_key):
|
||||
users = []
|
||||
users.extend(collection_meshes(collection_key))
|
||||
users.extend(collection_lights(collection_key))
|
||||
users.extend(collection_cameras(collection_key))
|
||||
users.extend(collection_others(collection_key))
|
||||
return distinct([name for name in users if object_all(name)])
|
||||
|
||||
|
||||
def sound_viewport_objects(sound_key):
|
||||
users = []
|
||||
try:
|
||||
sound = bpy.data.sounds[sound_key]
|
||||
except (KeyError, AttributeError):
|
||||
return []
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type != "SPEAKER":
|
||||
continue
|
||||
data = getattr(obj, "data", None)
|
||||
if data and getattr(data, "sound", None) == sound and object_all(obj.name):
|
||||
users.append(obj.name)
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def font_viewport_objects(font_key):
|
||||
users = []
|
||||
try:
|
||||
font = bpy.data.fonts[font_key]
|
||||
except (KeyError, AttributeError):
|
||||
return []
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type == "FONT" and obj.data == font and object_all(obj.name):
|
||||
users.append(obj.name)
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def distinct(seq):
|
||||
# returns a list of distinct elements
|
||||
|
||||
|
||||
@@ -139,6 +139,9 @@ def copy_prefs_to_config(self, context):
|
||||
config.enable_debug_prints = \
|
||||
atomic_preferences.enable_debug_prints
|
||||
|
||||
config.storage_navigate_frame_view = \
|
||||
atomic_preferences.storage_navigate_frame_view
|
||||
|
||||
# hidden atomic preferences
|
||||
config.pie_menu_type = \
|
||||
atomic_preferences.pie_menu_type
|
||||
@@ -254,6 +257,14 @@ class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
|
||||
default=False
|
||||
)
|
||||
|
||||
storage_navigate_frame_view: bpy.props.BoolProperty(
|
||||
name="Frame View on Storage Navigate",
|
||||
description="When clicking a storage index row, frame the user object "
|
||||
"in the 3D viewport (off by default: only selects and "
|
||||
"activates)",
|
||||
default=False,
|
||||
)
|
||||
|
||||
# hidden atomic preferences
|
||||
pie_menu_type: bpy.props.StringProperty(
|
||||
default="D"
|
||||
@@ -316,6 +327,13 @@ class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
|
||||
text="Enable Debug Prints"
|
||||
)
|
||||
|
||||
# storage navigation
|
||||
col.prop(
|
||||
self,
|
||||
"storage_navigate_frame_view",
|
||||
text="Frame View on Storage Navigate",
|
||||
)
|
||||
|
||||
# pie menu settings
|
||||
pie_split = col.split(factor=0.55) # nice
|
||||
|
||||
|
||||
@@ -35,10 +35,10 @@ from ..utils.compat import (
|
||||
format_bytes,
|
||||
format_embedded_total,
|
||||
get_report,
|
||||
storage_packed_icon,
|
||||
storage_override_icon,
|
||||
storage_type_icon,
|
||||
)
|
||||
from .utils import ui_layouts
|
||||
|
||||
|
||||
# Atomic Data Manager Statistics SubPanel
|
||||
@@ -139,13 +139,6 @@ class ATOMIC_PT_stats_panel(bpy.types.Panel):
|
||||
rep = storage_report
|
||||
|
||||
col = box.column(align=True)
|
||||
col.label(
|
||||
text="IDs linked from another .blend (library) are excluded.",
|
||||
icon='INFO',
|
||||
)
|
||||
col.label(text="Packed = exact bytes stored inside this .blend.")
|
||||
col.label(text="Other sizes are estimates; disk file may compress.")
|
||||
col.label(text="Second icon = library override (when applicable).")
|
||||
|
||||
row = col.row()
|
||||
row.label(
|
||||
@@ -166,11 +159,21 @@ class ATOMIC_PT_stats_panel(bpy.types.Panel):
|
||||
split = col.split(factor=0.68)
|
||||
left = split.row(align=True)
|
||||
left.label(icon=storage_type_icon(r["type"]), text="")
|
||||
left.label(icon=storage_packed_icon(r["type"]), text="")
|
||||
left.label(
|
||||
icon=storage_override_icon(r.get("is_lib_override", False)),
|
||||
text="",
|
||||
)
|
||||
left.label(text=r["name"])
|
||||
nav = left.operator(
|
||||
"atomic.storage_navigate",
|
||||
text=r["name"],
|
||||
emboss=False,
|
||||
)
|
||||
nav.storage_type = r["type"]
|
||||
nav.id_name = r.get("id_name", r["name"])
|
||||
nav.owner_object = r.get("owner_object", "")
|
||||
nav.owner_scene = r.get("owner_scene", "")
|
||||
nav.modifier_name = r.get("modifier_name", "")
|
||||
split.label(text=format_bytes(r["size_bytes"]))
|
||||
|
||||
if len(rep["rows"]) > 40:
|
||||
|
||||
@@ -4,6 +4,7 @@ between Blender 4.2 LTS, 4.5 LTS, and 5.0.
|
||||
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
import bpy
|
||||
from bpy.utils import register_class, unregister_class
|
||||
@@ -220,6 +221,31 @@ def _mesh_vertex_sum_sample():
|
||||
return s
|
||||
|
||||
|
||||
def _cache_modifier_counts():
|
||||
"""Cheap counts so storage cache invalidates when physics/GeoNodes mods change."""
|
||||
nodes = cloth = soft = dp = particles = 0
|
||||
for ob in bpy.data.objects:
|
||||
try:
|
||||
for mod in ob.modifiers:
|
||||
t = getattr(mod, "type", None)
|
||||
if t == "NODES":
|
||||
nodes += 1
|
||||
elif t == "CLOTH":
|
||||
cloth += 1
|
||||
elif t == "SOFT_BODY":
|
||||
soft += 1
|
||||
elif t == "DYNAMIC_PAINT":
|
||||
dp += 1
|
||||
particles += len(getattr(ob, "particle_systems", []))
|
||||
except (AttributeError, RuntimeError, ReferenceError):
|
||||
pass
|
||||
rb = 0
|
||||
for sc in bpy.data.scenes:
|
||||
if getattr(sc, "rigidbody_world", None):
|
||||
rb += 1
|
||||
return (nodes, cloth, soft, dp, particles, rb)
|
||||
|
||||
|
||||
def _light_fingerprint():
|
||||
fp = bpy.data.filepath
|
||||
try:
|
||||
@@ -236,6 +262,7 @@ def _light_fingerprint():
|
||||
len(bpy.data.objects),
|
||||
len(bpy.data.armatures),
|
||||
len(bpy.data.collections),
|
||||
_cache_modifier_counts(),
|
||||
)
|
||||
|
||||
|
||||
@@ -499,6 +526,269 @@ def format_bytes(n):
|
||||
return _fmt_bytes(n)
|
||||
|
||||
|
||||
# DNA layout of blender::NodesModifierBake (64-bit). bake_size is not in RNA.
|
||||
# See source/blender/makesdna/DNA_modifier_types.h
|
||||
class _NodesModifierBakeDNA(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("id", ctypes.c_int32),
|
||||
("flag", ctypes.c_uint32),
|
||||
("bake_mode", ctypes.c_uint8),
|
||||
("bake_target", ctypes.c_int8),
|
||||
("_pad", ctypes.c_char * 6),
|
||||
("directory", ctypes.c_void_p),
|
||||
("frame_start", ctypes.c_int32),
|
||||
("frame_end", ctypes.c_int32),
|
||||
("data_blocks_num", ctypes.c_int32),
|
||||
("active_data_block", ctypes.c_int32),
|
||||
("data_blocks", ctypes.c_void_p),
|
||||
("packed", ctypes.c_void_p),
|
||||
("_pad2", ctypes.c_void_p),
|
||||
("bake_size", ctypes.c_int64),
|
||||
]
|
||||
|
||||
|
||||
_PHYS_BYTES_PER_POINT = 32
|
||||
|
||||
|
||||
def _geonodes_bake_dna_sizes(bake):
|
||||
"""
|
||||
Read packed pointer and bake_size from NodesModifierBake DNA.
|
||||
Returns (bake_size, is_packed) or None if unavailable.
|
||||
"""
|
||||
if bake is None or ctypes.sizeof(ctypes.c_void_p) != 8:
|
||||
return None
|
||||
try:
|
||||
ptr = bake.as_pointer()
|
||||
if not ptr:
|
||||
return None
|
||||
dna = _NodesModifierBakeDNA.from_address(ptr)
|
||||
size = int(dna.bake_size)
|
||||
packed = bool(dna.packed)
|
||||
if size < 0:
|
||||
return None
|
||||
return (size, packed)
|
||||
except (AttributeError, TypeError, ValueError, OSError, RuntimeError):
|
||||
return None
|
||||
|
||||
|
||||
def _append_geonodes_bake_rows(rows, is_override):
|
||||
"""Append rows for GeoNodes bakes packed into the .blend."""
|
||||
# packed + bake_size DNA fields exist since Blender 4.3
|
||||
if not version.is_version_at_least(4, 3):
|
||||
return
|
||||
for ob in bpy.data.objects:
|
||||
if _skip_linked(ob):
|
||||
continue
|
||||
try:
|
||||
mods = ob.modifiers
|
||||
except (AttributeError, RuntimeError, ReferenceError):
|
||||
continue
|
||||
io = is_override(ob)
|
||||
ow = 0.08 if io else 1.0
|
||||
for mod in mods:
|
||||
if not is_geometry_nodes_modifier(mod):
|
||||
continue
|
||||
bakes = getattr(mod, "bakes", None)
|
||||
if not bakes:
|
||||
continue
|
||||
for bake in bakes:
|
||||
info = _geonodes_bake_dna_sizes(bake)
|
||||
if info is None:
|
||||
continue
|
||||
size, packed = info
|
||||
if not packed or size <= 0:
|
||||
continue
|
||||
node = getattr(bake, "node", None)
|
||||
node_name = getattr(node, "name", None) if node else None
|
||||
label = node_name or ("Bake %s" % getattr(bake, "bake_id", "?"))
|
||||
name = "%s / %s / %s" % (ob.name, mod.name, label)
|
||||
emb = max(1, int(size * ow))
|
||||
rows.append(
|
||||
{
|
||||
"type": "GeoNodesBake",
|
||||
"name": name,
|
||||
"embedded": emb,
|
||||
"size_bytes": emb,
|
||||
"is_lib_override": io,
|
||||
"kind": "packed",
|
||||
"owner_object": ob.name,
|
||||
"modifier_name": mod.name,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _point_cache_in_blend(cache):
|
||||
"""True when a point cache is baked into the .blend (not disk/external)."""
|
||||
if cache is None:
|
||||
return False
|
||||
try:
|
||||
if not cache.is_baked:
|
||||
return False
|
||||
if getattr(cache, "use_disk_cache", False):
|
||||
return False
|
||||
if getattr(cache, "use_external", False):
|
||||
return False
|
||||
return True
|
||||
except (AttributeError, RuntimeError, ReferenceError):
|
||||
return False
|
||||
|
||||
|
||||
def _iter_point_cache_items(pc):
|
||||
if pc is None:
|
||||
return
|
||||
items = getattr(pc, "point_caches", None)
|
||||
try:
|
||||
if items is not None and len(items) > 0:
|
||||
for item in items:
|
||||
yield item
|
||||
return
|
||||
except (AttributeError, RuntimeError, TypeError):
|
||||
pass
|
||||
yield pc
|
||||
|
||||
|
||||
def _physics_cache_size_bytes(cache, point_count):
|
||||
try:
|
||||
fs = int(cache.frame_start)
|
||||
fe = int(cache.frame_end)
|
||||
step = max(1, int(getattr(cache, "frame_step", 1) or 1))
|
||||
except (AttributeError, TypeError, ValueError, RuntimeError):
|
||||
return None
|
||||
frames = max(1, (fe - fs) // step + 1)
|
||||
pts = max(1, int(point_count) if point_count else 1)
|
||||
return max(64, frames * pts * _PHYS_BYTES_PER_POINT)
|
||||
|
||||
|
||||
def _object_mesh_vert_count(ob):
|
||||
data = getattr(ob, "data", None)
|
||||
if data is None:
|
||||
return 1
|
||||
try:
|
||||
return max(1, len(data.vertices))
|
||||
except (AttributeError, RuntimeError, ReferenceError, TypeError):
|
||||
return 1
|
||||
|
||||
|
||||
def _append_physics_cache_row(
|
||||
rows, name, cache, point_count, is_lib_override, owner_object="", owner_scene=""
|
||||
):
|
||||
if not _point_cache_in_blend(cache):
|
||||
return
|
||||
sz = _physics_cache_size_bytes(cache, point_count)
|
||||
if sz is None:
|
||||
return
|
||||
ow = 0.08 if is_lib_override else 1.0
|
||||
sz = max(64, int(sz * ow))
|
||||
cname = getattr(cache, "name", "") or ""
|
||||
display = "%s (%s)" % (name, cname) if cname else name
|
||||
row = {
|
||||
"type": "PhysicsCache",
|
||||
"name": display,
|
||||
"embedded": 0,
|
||||
"size_bytes": sz,
|
||||
"is_lib_override": is_lib_override,
|
||||
"kind": "override" if is_lib_override else "local",
|
||||
}
|
||||
if owner_object:
|
||||
row["owner_object"] = owner_object
|
||||
if owner_scene:
|
||||
row["owner_scene"] = owner_scene
|
||||
rows.append(row)
|
||||
|
||||
|
||||
def _append_physics_cache_rows(rows, is_override):
|
||||
"""Append rows for physics point caches stored inside the .blend."""
|
||||
for ob in bpy.data.objects:
|
||||
if _skip_linked(ob):
|
||||
continue
|
||||
io = is_override(ob)
|
||||
verts = _object_mesh_vert_count(ob)
|
||||
try:
|
||||
mods = ob.modifiers
|
||||
except (AttributeError, RuntimeError, ReferenceError):
|
||||
mods = []
|
||||
for mod in mods:
|
||||
mtype = getattr(mod, "type", None)
|
||||
if mtype == "CLOTH":
|
||||
pc = getattr(mod, "point_cache", None)
|
||||
for item in _iter_point_cache_items(pc):
|
||||
_append_physics_cache_row(
|
||||
rows, "%s / Cloth" % ob.name, item, verts, io, owner_object=ob.name
|
||||
)
|
||||
elif mtype == "SOFT_BODY":
|
||||
pc = getattr(mod, "point_cache", None)
|
||||
for item in _iter_point_cache_items(pc):
|
||||
_append_physics_cache_row(
|
||||
rows, "%s / Soft Body" % ob.name, item, verts, io, owner_object=ob.name
|
||||
)
|
||||
elif mtype == "DYNAMIC_PAINT":
|
||||
canvas = getattr(mod, "canvas_settings", None)
|
||||
surfaces = getattr(canvas, "canvas_surfaces", None) if canvas else None
|
||||
if not surfaces:
|
||||
continue
|
||||
for surf in surfaces:
|
||||
pc = getattr(surf, "point_cache", None)
|
||||
fmt = getattr(surf, "surface_format", None)
|
||||
if fmt == "IMAGE":
|
||||
res = int(getattr(surf, "image_resolution", 256) or 256)
|
||||
pts = max(1, res * res)
|
||||
else:
|
||||
pts = verts
|
||||
sname = getattr(surf, "name", "Surface") or "Surface"
|
||||
for item in _iter_point_cache_items(pc):
|
||||
_append_physics_cache_row(
|
||||
rows,
|
||||
"%s / Dynamic Paint / %s" % (ob.name, sname),
|
||||
item,
|
||||
pts,
|
||||
io,
|
||||
owner_object=ob.name,
|
||||
)
|
||||
try:
|
||||
psystems = ob.particle_systems
|
||||
except (AttributeError, RuntimeError, ReferenceError):
|
||||
psystems = []
|
||||
for ps in psystems:
|
||||
pc = getattr(ps, "point_cache", None)
|
||||
settings = getattr(ps, "settings", None)
|
||||
count = 1
|
||||
if settings is not None:
|
||||
try:
|
||||
count = max(1, int(getattr(settings, "count", 1) or 1))
|
||||
except (TypeError, ValueError):
|
||||
count = 1
|
||||
ps_name = getattr(ps, "name", "Particles") or "Particles"
|
||||
for item in _iter_point_cache_items(pc):
|
||||
_append_physics_cache_row(
|
||||
rows, "%s / %s" % (ob.name, ps_name), item, count, io, owner_object=ob.name
|
||||
)
|
||||
|
||||
for sc in bpy.data.scenes:
|
||||
if _skip_linked(sc):
|
||||
continue
|
||||
rbw = getattr(sc, "rigidbody_world", None)
|
||||
if not rbw:
|
||||
continue
|
||||
pc = getattr(rbw, "point_cache", None)
|
||||
coll = getattr(rbw, "collection", None)
|
||||
nobj = 1
|
||||
if coll is not None:
|
||||
try:
|
||||
nobj = max(1, len(coll.objects))
|
||||
except (AttributeError, RuntimeError, ReferenceError):
|
||||
nobj = 1
|
||||
io = is_override(sc)
|
||||
for item in _iter_point_cache_items(pc):
|
||||
_append_physics_cache_row(
|
||||
rows,
|
||||
"%s / Rigid Body" % sc.name,
|
||||
item,
|
||||
nobj,
|
||||
io,
|
||||
owner_scene=sc.name,
|
||||
)
|
||||
|
||||
|
||||
_STORAGE_TYPE_ICONS = {
|
||||
"Mesh": "MESH_DATA",
|
||||
"Image": "IMAGE_DATA",
|
||||
@@ -514,6 +804,8 @@ _STORAGE_TYPE_ICONS = {
|
||||
"Sound": "SOUND",
|
||||
"Font": "FONT_DATA",
|
||||
"Collection": "OUTLINER_COLLECTION",
|
||||
"GeoNodesBake": "GEOMETRY_NODES",
|
||||
"PhysicsCache": "PHYSICS",
|
||||
}
|
||||
|
||||
|
||||
@@ -522,8 +814,13 @@ def storage_type_icon(type_name):
|
||||
return _STORAGE_TYPE_ICONS.get(type_name, "BLANK1")
|
||||
|
||||
|
||||
def storage_packed_icon(type_name):
|
||||
"""Packed-in-blend indicator (GeoNodes bake rows only)."""
|
||||
return "PACKAGE" if type_name == "GeoNodesBake" else "BLANK1"
|
||||
|
||||
|
||||
def storage_override_icon(is_lib_override):
|
||||
"""Second column: library override emblem vs empty spacer."""
|
||||
"""Library override emblem vs empty spacer."""
|
||||
return "LIBRARY_DATA_OVERRIDE" if is_lib_override else "BLANK1"
|
||||
|
||||
|
||||
@@ -758,6 +1055,9 @@ def build_report():
|
||||
}
|
||||
)
|
||||
|
||||
_append_geonodes_bake_rows(rows, _ov)
|
||||
_append_physics_cache_rows(rows, _ov)
|
||||
|
||||
rows.sort(key=lambda r: r["size_bytes"], reverse=True)
|
||||
|
||||
by_type = {}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Navigate from Atomic Storage rows to datablock users in the Blender UI.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
from .. import config
|
||||
from ..stats import users
|
||||
|
||||
|
||||
def _find_area(context, area_type):
|
||||
screen = context.screen
|
||||
if context.window:
|
||||
screen = context.window.screen
|
||||
for area in screen.areas:
|
||||
if area.type == area_type:
|
||||
region = next((r for r in area.regions if r.type == "WINDOW"), None)
|
||||
return context.window, area, region
|
||||
return context.window, None, None
|
||||
|
||||
|
||||
def resolve_targets(storage_type, id_name, owner_object="", owner_scene="", modifier_name=""):
|
||||
"""
|
||||
Return navigation targets as dicts:
|
||||
object, material, action, nav (viewport|shader|dopesheet).
|
||||
"""
|
||||
targets = []
|
||||
|
||||
def add_objects(names, nav="viewport", material="", action=""):
|
||||
for name in names:
|
||||
if name in bpy.data.objects:
|
||||
targets.append(
|
||||
{
|
||||
"object": name,
|
||||
"material": material,
|
||||
"action": action,
|
||||
"nav": nav,
|
||||
"modifier": modifier_name if owner_object == name else "",
|
||||
}
|
||||
)
|
||||
|
||||
if storage_type == "Object":
|
||||
if id_name in bpy.data.objects:
|
||||
add_objects([id_name])
|
||||
return targets
|
||||
|
||||
if storage_type == "GeoNodesBake":
|
||||
if owner_object:
|
||||
add_objects([owner_object])
|
||||
return targets
|
||||
|
||||
if storage_type == "PhysicsCache":
|
||||
if owner_object:
|
||||
add_objects([owner_object])
|
||||
return targets
|
||||
if owner_scene:
|
||||
scene = bpy.data.scenes.get(owner_scene)
|
||||
rbw = getattr(scene, "rigidbody_world", None) if scene else None
|
||||
coll = getattr(rbw, "collection", None) if rbw else None
|
||||
if coll:
|
||||
add_objects([ob.name for ob in coll.objects])
|
||||
return targets
|
||||
|
||||
if storage_type == "Mesh":
|
||||
add_objects(users.mesh_objects(id_name))
|
||||
elif storage_type == "Curve":
|
||||
add_objects(users.curve_objects(id_name))
|
||||
elif storage_type == "Armature":
|
||||
add_objects(users.armature_all(id_name))
|
||||
elif storage_type == "Volume":
|
||||
add_objects(users.volume_objects(id_name))
|
||||
elif storage_type == "PointCloud":
|
||||
add_objects(users.pointcloud_objects(id_name))
|
||||
elif storage_type == "Image":
|
||||
add_objects(users.image_viewport_objects(id_name))
|
||||
elif storage_type == "Material":
|
||||
seen = set()
|
||||
for name in users.material_objects(id_name):
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
add_objects([name], nav="shader", material=id_name)
|
||||
for name in users.material_geometry_nodes(id_name):
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
add_objects([name], nav="shader", material=id_name)
|
||||
elif storage_type == "Action":
|
||||
for name in users.action_objects(id_name):
|
||||
add_objects([name], nav="dopesheet", action=id_name)
|
||||
elif storage_type == "NodeTree":
|
||||
add_objects(users.node_group_viewport_objects(id_name))
|
||||
elif storage_type == "Texture":
|
||||
add_objects(users.texture_objects(id_name))
|
||||
elif storage_type == "Collection":
|
||||
add_objects(users.collection_viewport_objects(id_name))
|
||||
elif storage_type == "Sound":
|
||||
add_objects(users.sound_viewport_objects(id_name))
|
||||
elif storage_type == "Font":
|
||||
add_objects(users.font_viewport_objects(id_name))
|
||||
|
||||
return targets
|
||||
|
||||
|
||||
def _select_object(context, object_name):
|
||||
vl = context.view_layer
|
||||
if object_name not in vl.objects:
|
||||
return False
|
||||
for ob in vl.objects:
|
||||
ob.select_set(False)
|
||||
ob = vl.objects[object_name]
|
||||
ob.select_set(True)
|
||||
vl.objects.active = ob
|
||||
return True
|
||||
|
||||
|
||||
def _frame_view3d(context):
|
||||
window, area, region = _find_area(context, "VIEW_3D")
|
||||
if not area or not region:
|
||||
return
|
||||
with context.temp_override(window=window, area=area, region=region):
|
||||
try:
|
||||
bpy.ops.view3d.view_selected()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
def _outliner_show_active(context):
|
||||
window, area, region = _find_area(context, "OUTLINER")
|
||||
if not area or not region:
|
||||
return
|
||||
with context.temp_override(window=window, area=area, region=region):
|
||||
try:
|
||||
bpy.ops.outliner.show_active()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
def _focus_shader_editor(context, material_name):
|
||||
material = bpy.data.materials.get(material_name)
|
||||
if not material:
|
||||
return
|
||||
ob = context.view_layer.objects.active
|
||||
if ob and hasattr(ob, "material_slots"):
|
||||
for i, slot in enumerate(ob.material_slots):
|
||||
if slot.material == material:
|
||||
ob.active_material_index = i
|
||||
break
|
||||
window, area, _region = _find_area(context, "NODE_EDITOR")
|
||||
if not area:
|
||||
return
|
||||
space = area.spaces.active
|
||||
if space.type != "NODE_EDITOR":
|
||||
return
|
||||
space.tree_type = "ShaderNodeTree"
|
||||
if hasattr(space, "shader_type"):
|
||||
space.shader_type = "OBJECT"
|
||||
if hasattr(space, "pin"):
|
||||
space.pin = False
|
||||
|
||||
|
||||
def _focus_dopesheet(context, action_name):
|
||||
action = bpy.data.actions.get(action_name)
|
||||
window, area, _region = _find_area(context, "DOPESHEET_EDITOR")
|
||||
if not area:
|
||||
return
|
||||
space = area.spaces.active
|
||||
if space.type != "DOPESHEET_EDITOR":
|
||||
return
|
||||
if action and hasattr(space, "action"):
|
||||
space.action = action
|
||||
|
||||
|
||||
def apply_target(context, target):
|
||||
"""Select object and open the appropriate editor for one target."""
|
||||
if not _select_object(context, target["object"]):
|
||||
return False
|
||||
|
||||
nav = target.get("nav", "viewport")
|
||||
if nav == "shader" and target.get("material"):
|
||||
_focus_shader_editor(context, target["material"])
|
||||
elif nav == "dopesheet" and target.get("action"):
|
||||
_focus_dopesheet(context, target["action"])
|
||||
|
||||
if config.storage_navigate_frame_view:
|
||||
_frame_view3d(context)
|
||||
_outliner_show_active(context)
|
||||
return True
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ schema_version = "1.0.0"
|
||||
id = "basedplayblast"
|
||||
name = "BasedPlayblast"
|
||||
tagline = "Easily create playblasts from Blender and Flamenco"
|
||||
version = "2.6.3"
|
||||
version = "2.8.1"
|
||||
type = "add-on"
|
||||
|
||||
maintainer = "RaincloudTheDragon <raincloudthedragon@gmail.com>"
|
||||
|
||||
Binary file not shown.
@@ -5,8 +5,8 @@ Grouped here so Blender version/compatibility helpers stay isolated from the
|
||||
main add-on module.
|
||||
"""
|
||||
|
||||
from . import version, compat
|
||||
from . import version, compat, encode, ffmpeg_path
|
||||
|
||||
__all__ = ["version", "compat"]
|
||||
__all__ = ["version", "compat", "encode", "ffmpeg_path"]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""FFmpeg video encode helpers for BasedPlayblast."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
ENCODE_SPEED_ITEMS = [
|
||||
('FASTEST', "Fastest", "Fastest encode; lowest quality preset"),
|
||||
('FAST', "Fast", "Fast encode"),
|
||||
('MEDIUM', "Medium", "Balanced encode speed"),
|
||||
('SLOW', "Slow", "Slower encode with better compression efficiency"),
|
||||
('SLOWEST', "Slowest", "Slowest preset; best detail preservation (default)"),
|
||||
]
|
||||
|
||||
_NVENC_PRESETS = {
|
||||
'FASTEST': 'p1',
|
||||
'FAST': 'p3',
|
||||
'MEDIUM': 'p5',
|
||||
'SLOW': 'p6',
|
||||
'SLOWEST': 'p7',
|
||||
}
|
||||
|
||||
_LIBX264_PRESETS = {
|
||||
'FASTEST': 'ultrafast',
|
||||
'FAST': 'veryfast',
|
||||
'MEDIUM': 'medium',
|
||||
'SLOW': 'slow',
|
||||
'SLOWEST': 'veryslow',
|
||||
}
|
||||
|
||||
_NVENC_ENCODERS = ('av1_nvenc', 'hevc_nvenc', 'h264_nvenc')
|
||||
_ENCODER_PRIORITY = _NVENC_ENCODERS + ('libx264',)
|
||||
|
||||
_ENCODER_CACHE: dict[str, str] = {}
|
||||
|
||||
_NVENC_DRIVER_MARKERS = (
|
||||
'does not support the required nvenc api version',
|
||||
'minimum required nvidia driver',
|
||||
'no nvenc capable devices found',
|
||||
'driver does not support',
|
||||
)
|
||||
|
||||
|
||||
def _encoder_list_text(ffmpeg_path: str) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[ffmpeg_path, '-hide_banner', '-encoders'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
check=False,
|
||||
)
|
||||
return (result.stdout or '') + (result.stderr or '')
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ''
|
||||
|
||||
|
||||
def list_encoder_candidates(ffmpeg_path: str) -> list[str]:
|
||||
"""Return available encoders in preference order (NVENC family, then libx264).
|
||||
|
||||
A previously successful encoder for this ffmpeg path is tried first.
|
||||
"""
|
||||
encoders = _encoder_list_text(ffmpeg_path)
|
||||
available = [name for name in _ENCODER_PRIORITY if name in encoders]
|
||||
if not available:
|
||||
available = ['libx264']
|
||||
|
||||
cached = _ENCODER_CACHE.get(ffmpeg_path)
|
||||
if cached and cached in available:
|
||||
return [cached] + [name for name in available if name != cached]
|
||||
return available
|
||||
|
||||
|
||||
def detect_video_encoder(ffmpeg_path: str) -> str:
|
||||
"""Pick the best available encoder: av1_nvenc, hevc_nvenc, h264_nvenc, then libx264."""
|
||||
return list_encoder_candidates(ffmpeg_path)[0]
|
||||
|
||||
|
||||
def remember_working_encoder(ffmpeg_path: str, encoder: str) -> None:
|
||||
"""Cache an encoder that successfully encoded on this machine."""
|
||||
_ENCODER_CACHE[ffmpeg_path] = encoder
|
||||
|
||||
|
||||
def is_nvenc_encoder(encoder: str) -> bool:
|
||||
return encoder in _NVENC_ENCODERS
|
||||
|
||||
|
||||
def is_nvenc_driver_error(stderr: str) -> bool:
|
||||
"""True when FFmpeg failed because the NVIDIA driver is too old / incompatible."""
|
||||
text = (stderr or '').lower()
|
||||
return any(marker in text for marker in _NVENC_DRIVER_MARKERS)
|
||||
|
||||
|
||||
def _vbr_bitrate_args(bitrate_limit_mbps: int) -> list[str]:
|
||||
"""Optional NVENC VBR bitrate cap. 0 = uncapped."""
|
||||
if bitrate_limit_mbps <= 0:
|
||||
return []
|
||||
return [
|
||||
'-b:v', f'{bitrate_limit_mbps}M',
|
||||
'-maxrate', f'{bitrate_limit_mbps}M',
|
||||
'-bufsize', f'{bitrate_limit_mbps * 2}M',
|
||||
]
|
||||
|
||||
|
||||
def _nvenc_encode_args(
|
||||
encoder: str,
|
||||
encode_speed: str,
|
||||
bitrate_limit_mbps: int,
|
||||
) -> list[str]:
|
||||
"""GigaMux-style NVENC VBR: hq tune, spatial AQ, cq 0; optional bitrate cap."""
|
||||
speed = encode_speed if encode_speed in _NVENC_PRESETS else 'SLOWEST'
|
||||
args = [
|
||||
'-c:v', encoder,
|
||||
'-preset', _NVENC_PRESETS[speed],
|
||||
'-tune', 'hq',
|
||||
'-rc', 'vbr',
|
||||
'-rc-lookahead', '32',
|
||||
'-spatial-aq', '1',
|
||||
'-aq-strength', '15',
|
||||
'-cq', '0',
|
||||
]
|
||||
args.extend(_vbr_bitrate_args(bitrate_limit_mbps))
|
||||
return args
|
||||
|
||||
|
||||
def build_video_encode_args_for(
|
||||
encoder: str,
|
||||
encode_speed: str = 'SLOWEST',
|
||||
bitrate_limit_mbps: int = 0,
|
||||
) -> list[str]:
|
||||
"""Build FFmpeg video encode args for a specific encoder."""
|
||||
if encoder in _NVENC_ENCODERS:
|
||||
return _nvenc_encode_args(encoder, encode_speed, bitrate_limit_mbps)
|
||||
|
||||
# CRF/QP 0 is true lossless and forces High 4:4:4 Predictive (breaks WMP).
|
||||
# CRF 1 is the lowest lossy setting that stays High + yuv420p — nearest to NVENC cq 0.
|
||||
speed = encode_speed if encode_speed in _LIBX264_PRESETS else 'SLOWEST'
|
||||
return [
|
||||
'-c:v', 'libx264',
|
||||
'-preset', _LIBX264_PRESETS[speed],
|
||||
'-crf', '1',
|
||||
'-profile:v', 'high',
|
||||
]
|
||||
|
||||
|
||||
def build_video_encode_args(
|
||||
ffmpeg_path: str,
|
||||
encode_speed: str = 'SLOWEST',
|
||||
bitrate_limit_mbps: int = 0,
|
||||
) -> list[str]:
|
||||
"""Build FFmpeg video encode args for visually lossless playblast output."""
|
||||
encoder = detect_video_encoder(ffmpeg_path)
|
||||
return build_video_encode_args_for(
|
||||
encoder,
|
||||
encode_speed=encode_speed,
|
||||
bitrate_limit_mbps=bitrate_limit_mbps,
|
||||
)
|
||||
|
||||
|
||||
def default_custom_ffmpeg_args(ffmpeg_path: str, encode_speed: str = 'SLOWEST') -> str:
|
||||
"""Serialize the auto-detected encode args for the custom-args text field."""
|
||||
return ' '.join(build_video_encode_args(ffmpeg_path, encode_speed=encode_speed))
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Locate an ffmpeg executable for BasedPlayblast."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
_FFMPEG_BOOTSTRAP_ATTEMPTED = False
|
||||
|
||||
# Official Windows essentials build recommended for Blender-adjacent tooling.
|
||||
_FFMPEG_ESSENTIALS_URL = (
|
||||
"https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"
|
||||
)
|
||||
|
||||
|
||||
def addon_root() -> str:
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def bundled_ffmpeg_path() -> str:
|
||||
return os.path.join(addon_root(), "tools", "ffmpeg.exe")
|
||||
|
||||
|
||||
def iter_blender_bundled_ffmpeg_paths():
|
||||
"""Yield likely paths to ffmpeg shipped with this Blender install."""
|
||||
try:
|
||||
import bpy # type: ignore
|
||||
|
||||
binary = bpy.app.binary_path
|
||||
if not binary:
|
||||
return
|
||||
binary_dir = os.path.dirname(binary)
|
||||
version = f"{bpy.app.version[0]}.{bpy.app.version[1]}"
|
||||
names = ("ffmpeg.exe", "ffmpeg") if sys.platform == "win32" else ("ffmpeg",)
|
||||
|
||||
roots = [binary_dir]
|
||||
parent = os.path.dirname(binary_dir)
|
||||
if parent:
|
||||
roots.append(parent)
|
||||
if sys.platform == "darwin":
|
||||
resources = os.path.join(os.path.dirname(binary_dir), "Resources")
|
||||
if os.path.isdir(resources):
|
||||
roots.append(resources)
|
||||
roots.append(os.path.join(resources, version))
|
||||
|
||||
subdirs = (
|
||||
"",
|
||||
version,
|
||||
"ffmpeg",
|
||||
os.path.join(version, "ffmpeg"),
|
||||
os.path.join(version, "ffmpeg", "bin"),
|
||||
os.path.join("ffmpeg", "bin"),
|
||||
)
|
||||
|
||||
seen = set()
|
||||
for root in roots:
|
||||
for sub in subdirs:
|
||||
for name in names:
|
||||
path = os.path.normpath(
|
||||
os.path.join(root, sub, name) if sub else os.path.join(root, name)
|
||||
)
|
||||
if path not in seen:
|
||||
seen.add(path)
|
||||
yield path
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _bootstrap_bundled_ffmpeg() -> str | None:
|
||||
"""Download ffmpeg essentials into addon tools/ when no executable is found."""
|
||||
global _FFMPEG_BOOTSTRAP_ATTEMPTED
|
||||
if _FFMPEG_BOOTSTRAP_ATTEMPTED or sys.platform != "win32":
|
||||
return None
|
||||
_FFMPEG_BOOTSTRAP_ATTEMPTED = True
|
||||
|
||||
target = bundled_ffmpeg_path()
|
||||
if os.path.isfile(target):
|
||||
return target
|
||||
|
||||
tools_dir = os.path.dirname(target)
|
||||
os.makedirs(tools_dir, exist_ok=True)
|
||||
|
||||
print("[BasedPlayblast] No ffmpeg found; downloading essentials build to addon tools/...")
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="basedplayblast_ffmpeg_") as tmp_dir:
|
||||
zip_path = os.path.join(tmp_dir, "ffmpeg-essentials.zip")
|
||||
with urllib.request.urlopen(_FFMPEG_ESSENTIALS_URL, timeout=120) as response:
|
||||
with open(zip_path, "wb") as handle:
|
||||
shutil.copyfileobj(response, handle)
|
||||
|
||||
with zipfile.ZipFile(zip_path) as archive:
|
||||
member = next(
|
||||
(name for name in archive.namelist() if name.endswith("/bin/ffmpeg.exe")),
|
||||
None,
|
||||
)
|
||||
if not member:
|
||||
print("[BasedPlayblast] ffmpeg bootstrap failed: ffmpeg.exe not found in archive")
|
||||
return None
|
||||
archive.extract(member, tmp_dir)
|
||||
extracted = os.path.join(tmp_dir, member.replace("/", os.sep))
|
||||
shutil.copy2(extracted, target)
|
||||
|
||||
if os.path.isfile(target):
|
||||
print(f"[BasedPlayblast] Installed bundled ffmpeg: {target}")
|
||||
return target
|
||||
except Exception as exc:
|
||||
print(f"[BasedPlayblast] ffmpeg bootstrap failed: {exc}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def resolve_ffmpeg_path() -> str:
|
||||
"""Resolve ffmpeg: addon override, addon bundle, Blender, bootstrap, then PATH."""
|
||||
try:
|
||||
import bpy # type: ignore
|
||||
|
||||
for addon_name in (
|
||||
"BasedPlayblast",
|
||||
"basedplayblast",
|
||||
"bl_ext.basedplayblast",
|
||||
"bl_ext.user_local.basedplayblast",
|
||||
):
|
||||
prefs = bpy.context.preferences.addons.get(addon_name)
|
||||
if not prefs or not prefs.preferences:
|
||||
continue
|
||||
if hasattr(prefs.preferences, "ffmpeg_path"):
|
||||
custom = getattr(prefs.preferences, "ffmpeg_path", "").strip()
|
||||
if custom and os.path.isfile(custom):
|
||||
return custom
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
bundled = bundled_ffmpeg_path()
|
||||
if os.path.isfile(bundled):
|
||||
return bundled
|
||||
|
||||
for candidate in iter_blender_bundled_ffmpeg_paths():
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
|
||||
bootstrapped = _bootstrap_bundled_ffmpeg()
|
||||
if bootstrapped and os.path.isfile(bootstrapped):
|
||||
return bootstrapped
|
||||
|
||||
exe = shutil.which("ffmpeg")
|
||||
if exe:
|
||||
print(
|
||||
f"[BasedPlayblast] Warning: no addon-bundled ffmpeg; falling back to PATH: {exe}"
|
||||
)
|
||||
return exe
|
||||
|
||||
for candidate in (
|
||||
r"C:\ProgramData\chocolatey\bin\ffmpeg.exe",
|
||||
r"C:\Program Files\ffmpeg\bin\ffmpeg.exe",
|
||||
r"C:\Program Files (x86)\ffmpeg\bin\ffmpeg.exe",
|
||||
r"C:\ffmpeg\bin\ffmpeg.exe",
|
||||
):
|
||||
if candidate and os.path.isfile(candidate):
|
||||
return candidate
|
||||
|
||||
return "ffmpeg"
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Deploy BasedPlayblast job compiler scripts to a Flamenco Manager install."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
MANAGER_CONFIG_NAME = "flamenco-manager.yaml"
|
||||
SCRIPTS_DIR_NAME = "scripts"
|
||||
|
||||
SCRIPT_FILENAMES = (
|
||||
"BasedPlayblast.js",
|
||||
"BasedPlayblast_Optix_GPU.js",
|
||||
)
|
||||
|
||||
# Flamenco 3.7+ loads job compiler scripts on demand (no Manager restart).
|
||||
LIVE_SCRIPTS_MIN_VERSION = (3, 7, 0)
|
||||
|
||||
|
||||
def addon_root() -> str:
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def bundled_scripts_dir() -> str:
|
||||
return os.path.join(addon_root(), "flamenco")
|
||||
|
||||
|
||||
def manager_config_path(manager_dir: str) -> str:
|
||||
return os.path.join(manager_dir, MANAGER_CONFIG_NAME)
|
||||
|
||||
|
||||
def manager_scripts_dir(manager_dir: str) -> str:
|
||||
return os.path.join(manager_dir, SCRIPTS_DIR_NAME)
|
||||
|
||||
|
||||
def read_manager_config(manager_dir: str) -> str:
|
||||
path = manager_config_path(manager_dir)
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
def validate_manager_dir(manager_dir: str) -> tuple[bool, str]:
|
||||
manager_dir = os.path.normpath(manager_dir)
|
||||
if not manager_dir or not os.path.isdir(manager_dir):
|
||||
return False, "Selected path is not a directory"
|
||||
|
||||
config_path = manager_config_path(manager_dir)
|
||||
if not os.path.isfile(config_path):
|
||||
return (
|
||||
False,
|
||||
f"{MANAGER_CONFIG_NAME} not found. Select the folder that contains the Flamenco Manager executable.",
|
||||
)
|
||||
|
||||
return True, config_path
|
||||
|
||||
|
||||
def _parse_listen_host_port(config_text: str) -> tuple[str, int]:
|
||||
match = re.search(r"^listen:\s*['\"]?([^'\"\n#]+)", config_text, re.MULTILINE)
|
||||
listen = match.group(1).strip() if match else ":8080"
|
||||
|
||||
if listen.startswith(":"):
|
||||
return "127.0.0.1", int(listen[1:])
|
||||
|
||||
if "://" in listen:
|
||||
listen = listen.split("://", 1)[1]
|
||||
|
||||
if ":" in listen:
|
||||
host, port_text = listen.rsplit(":", 1)
|
||||
host = host or "127.0.0.1"
|
||||
return host, int(port_text)
|
||||
|
||||
return listen or "127.0.0.1", 8080
|
||||
|
||||
|
||||
def _parse_version_string(version_text: str) -> tuple[int, int, int] | None:
|
||||
match = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", version_text)
|
||||
if not match:
|
||||
return None
|
||||
major = int(match.group(1))
|
||||
minor = int(match.group(2))
|
||||
patch = int(match.group(3) or 0)
|
||||
return major, minor, patch
|
||||
|
||||
|
||||
def query_manager_version(manager_dir: str) -> tuple[int, int, int] | None:
|
||||
"""Best-effort Manager version lookup via the local HTTP API."""
|
||||
try:
|
||||
config_text = read_manager_config(manager_dir)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
host, port = _parse_listen_host_port(config_text)
|
||||
endpoints = (
|
||||
f"http://{host}:{port}/api/v3/meta/version",
|
||||
f"http://{host}:{port}/api/v2/meta/version",
|
||||
f"http://{host}:{port}/version",
|
||||
)
|
||||
|
||||
for url in endpoints:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=2.0) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except (OSError, urllib.error.URLError, json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
|
||||
for key in ("version", "Version", "manager_version"):
|
||||
if key in payload:
|
||||
parsed = _parse_version_string(str(payload[key]))
|
||||
if parsed:
|
||||
return parsed
|
||||
|
||||
if isinstance(payload, dict) and "name" in payload and "version" in payload:
|
||||
parsed = _parse_version_string(str(payload["version"]))
|
||||
if parsed:
|
||||
return parsed
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def needs_manager_restart(version: tuple[int, int, int] | None) -> bool:
|
||||
if version is None:
|
||||
return False
|
||||
return version < LIVE_SCRIPTS_MIN_VERSION
|
||||
|
||||
|
||||
def deploy_scripts(manager_dir: str) -> tuple[bool, str, list[str], bool, tuple[int, int, int] | None]:
|
||||
"""
|
||||
Copy bundled Flamenco scripts into <manager>/scripts.
|
||||
|
||||
Returns:
|
||||
ok, user_message, copied_files, restart_recommended, detected_version
|
||||
"""
|
||||
ok, detail = validate_manager_dir(manager_dir)
|
||||
if not ok:
|
||||
return False, detail, [], False, None
|
||||
|
||||
source_dir = bundled_scripts_dir()
|
||||
missing_sources = [
|
||||
name for name in SCRIPT_FILENAMES
|
||||
if not os.path.isfile(os.path.join(source_dir, name))
|
||||
]
|
||||
if missing_sources:
|
||||
return False, f"Missing bundled scripts: {', '.join(missing_sources)}", [], False, None
|
||||
|
||||
try:
|
||||
read_manager_config(manager_dir)
|
||||
except OSError as exc:
|
||||
return False, f"Could not read {MANAGER_CONFIG_NAME}: {exc}", [], False, None
|
||||
|
||||
scripts_dir = manager_scripts_dir(manager_dir)
|
||||
os.makedirs(scripts_dir, exist_ok=True)
|
||||
|
||||
copied: list[str] = []
|
||||
for name in SCRIPT_FILENAMES:
|
||||
shutil.copy2(os.path.join(source_dir, name), os.path.join(scripts_dir, name))
|
||||
copied.append(name)
|
||||
|
||||
version = query_manager_version(manager_dir)
|
||||
restart = needs_manager_restart(version)
|
||||
|
||||
if version and restart:
|
||||
version_label = ".".join(str(part) for part in version)
|
||||
message = (
|
||||
f"Deployed {len(copied)} script(s) to {scripts_dir}. "
|
||||
f"Flamenco Manager {version_label} requires a restart to load new scripts "
|
||||
f"(live reload was added in 3.7)."
|
||||
)
|
||||
elif version:
|
||||
version_label = ".".join(str(part) for part in version)
|
||||
message = (
|
||||
f"Deployed {len(copied)} script(s) to {scripts_dir}. "
|
||||
f"Flamenco Manager {version_label} will load them on the next job compile."
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
f"Deployed {len(copied)} script(s) to {scripts_dir}. "
|
||||
"Flamenco 3.7+ loads scripts on demand; restart Manager if you are on an older version."
|
||||
)
|
||||
|
||||
return True, message, copied, restart, version
|
||||
Reference in New Issue
Block a user