2025-12-09

This commit is contained in:
2026-03-17 15:03:35 -06:00
parent 4b82b57113
commit aae574f8dc
137 changed files with 17355 additions and 4067 deletions
@@ -0,0 +1,12 @@
"""
Utility helpers for BasedPlayblast.
Grouped here so Blender version/compatibility helpers stay isolated from the
main add-on module.
"""
from . import version, compat
__all__ = ["version", "compat"]
@@ -0,0 +1,232 @@
"""
Compatibility helpers wrapping Blender version-specific logic.
Anything that differs between Blender 4.2 LTS, 4.5 LTS, and 5.0+ should live here
so the main add-on stays focused on user-facing behavior.
"""
from __future__ import annotations
import os
from typing import Iterable, Optional
try:
import bpy # type: ignore
from bpy.utils import register_class, unregister_class # type: ignore
except ImportError: # pragma: no cover - for static tooling
bpy = None # type: ignore
register_class = unregister_class = lambda cls: None # type: ignore
from . import version
# -- Registration helpers --------------------------------------------------
def safe_register_class(cls) -> bool:
try:
register_class(cls)
return True
except Exception as exc: # pragma: no cover - Blender runtime logging
print(f"[BasedPlayblast] register fail: {cls.__name__}: {exc}")
return False
def safe_unregister_class(cls) -> bool:
try:
unregister_class(cls)
return True
except Exception as exc: # pragma: no cover
print(f"[BasedPlayblast] unregister fail: {cls.__name__}: {exc}")
return False
# -- Scene/helpers ---------------------------------------------------------
def get_compositor_tree(scene):
"""
Return the compositor node tree, accounting for Blender 5.0 renames.
"""
if version.is_version_at_least(5, 0, 0):
return getattr(scene, "compositing_node_tree", None)
return getattr(scene, "node_tree", None)
def is_geometry_nodes_modifier(modifier) -> bool:
return getattr(modifier, "type", None) == "NODES"
def get_geometry_nodes_node_group(modifier):
if is_geometry_nodes_modifier(modifier):
return getattr(modifier, "node_group", None)
return None
# -- Render IO -------------------------------------------------------------
def set_video_file_format(scene) -> bool:
"""
Force Blender onto a video-friendly output. Returns True if a usable
format was chosen; False means callers should warn/abort.
- Blender 4.2/4.5: Can set FFMPEG directly for direct video output
- Blender 5.0+: image_settings.file_format no longer includes video formats.
We use PNG with 0% compression (fast, lossless) and encode frames manually.
"""
if not scene or not getattr(scene, "render", None):
return False
render = scene.render
is_blender_5 = version.is_version_at_least(5, 0, 0)
if not is_blender_5:
# Blender 4.2/4.5: Can set FFMPEG directly for direct video output
try:
render.image_settings.file_format = "FFMPEG"
return True
except Exception as exc:
print(f"[BasedPlayblast] FFMPEG set failed: {exc}")
return False
# Blender 5.0+: image_settings.file_format only supports image formats
# Use PNG with 0% compression - fast writes, lossless quality, then encode to video
if hasattr(render, "ffmpeg"):
try:
render.image_settings.file_format = "PNG"
render.image_settings.compression = 0 # 0% compression = fastest PNG writes
print("[BasedPlayblast] Blender 5.0: Using PNG with 0% compression "
"(fast, lossless quality). Blender 5.0 removed video formats from "
"image_settings.file_format, so we encode frames to video manually.")
return True
except Exception as exc:
print(f"[BasedPlayblast] PNG with 0% compression failed: {exc}")
# Last resort: PNG with default compression
try:
render.image_settings.file_format = "PNG"
print("[BasedPlayblast] video fallback -> PNG sequence (will encode manually)")
return False
except Exception as exc:
print(f"[BasedPlayblast] PNG fallback failed: {exc}")
return False
def viewport_opengl_render(context, area=None, region=None):
"""
Invoke viewport OpenGL animation render with overrides tuned per version.
"""
if bpy is None:
raise RuntimeError("bpy unavailable")
is_blender_5 = version.is_version_at_least(5, 0, 0)
def _call(**override_kwargs):
with context.temp_override(**override_kwargs):
bpy.ops.render.opengl(
"INVOKE_DEFAULT",
animation=True,
sequencer=False,
write_still=False,
**({"view_context": True} if not is_blender_5 else {}),
)
def _resolve_region(target_area, candidate_region):
if candidate_region and getattr(candidate_region, "type", None) == "WINDOW":
return candidate_region
if target_area:
for reg in target_area.regions:
if getattr(reg, "type", None) == "WINDOW":
return reg
return None
target_region = _resolve_region(area, region)
try:
if area and target_region:
override = context.copy()
override["area"] = area
override["region"] = target_region
_call(**override)
return True
bpy.ops.render.opengl(
"INVOKE_DEFAULT", animation=True, sequencer=False, write_still=False
)
return True
except TypeError:
# Blender 5 requires explicit view_context flag in some builds
if area and target_region:
override = context.copy()
override["area"] = area
override["region"] = target_region
with context.temp_override(**override):
bpy.ops.render.opengl(
"INVOKE_DEFAULT",
animation=True,
sequencer=False,
write_still=False,
view_context=False,
)
return True
bpy.ops.render.opengl(
"INVOKE_DEFAULT",
animation=True,
sequencer=False,
write_still=False,
view_context=False,
)
return True
except Exception as exc:
print(f"[BasedPlayblast] OpenGL render failed: {exc}")
raise
# -- Studio lights helpers --------------------------------------------------
def iter_studio_light_dirs(blender_binary_path: Optional[str]) -> Iterable[str]:
if not blender_binary_path:
return []
blender_dir = os.path.dirname(blender_binary_path)
version_token = version.get_version_category().split("+")[0]
candidates = [
os.path.join(blender_dir, "datafiles", "studiolights", "world"),
os.path.join(blender_dir, version_token, "datafiles", "studiolights", "world"),
os.path.join(os.path.dirname(blender_dir), version_token, "datafiles", "studiolights", "world"),
os.path.join(os.path.dirname(os.path.dirname(blender_binary_path)), version_token, "datafiles", "studiolights", "world"),
os.path.join("C:\\Program Files\\Blender Foundation", f"Blender {version_token}", version_token, "datafiles", "studiolights", "world"),
]
seen = set()
for path in candidates:
if path and path not in seen:
seen.add(path)
yield path
def find_first_existing_path(paths: Iterable[str]) -> Optional[str]:
for path in paths:
if path and os.path.exists(path):
return path
return None
def resolve_hdri_path(studio_dir: Optional[str]) -> Optional[str]:
if not studio_dir:
return None
preferred = [
"forest.exr",
"studio.exr",
"city.exr",
"courtyard.exr",
"night.exr",
"sunrise.exr",
"sunset.exr",
]
for fname in preferred:
candidate = os.path.join(studio_dir, fname)
if os.path.exists(candidate):
return candidate
try:
for entry in os.listdir(studio_dir):
if entry.lower().endswith(".exr"):
return os.path.join(studio_dir, entry)
except Exception as exc:
print(f"[BasedPlayblast] Studio dir listing failed: {exc}")
return None
@@ -0,0 +1,65 @@
"""
Blender version helpers for BasedPlayblast.
Keeps the add-on logic clean by centralizing all version comparisons and
common constants for the supported tracks (4.2 LTS, 4.5 LTS, 5.0+).
"""
from __future__ import annotations
try: # Blender runtime
import bpy # type: ignore
except ImportError: # During static analysis or packaging
bpy = None # type: ignore
# Targeted anchors
VERSION_4_2_LTS = (4, 2, 0)
VERSION_4_5_LTS = (4, 5, 0)
VERSION_5_0 = (5, 0, 0)
def _current_version() -> tuple[int, int, int]:
"""Return Blender's version tuple or a fallback."""
if bpy and getattr(bpy.app, "version", None):
return bpy.app.version # type: ignore[return-value]
return (0, 0, 0)
def get_blender_version() -> tuple[int, int, int]:
return _current_version()
def get_version_string() -> str:
v = _current_version()
return f"{v[0]}.{v[1]}.{v[2]}"
def is_version_at_least(major: int, minor: int = 0, patch: int = 0) -> bool:
current = _current_version()
return current >= (major, minor, patch)
def is_version_less_than(major: int, minor: int = 0, patch: int = 0) -> bool:
current = _current_version()
return current < (major, minor, patch)
def get_version_category() -> str:
"""
Collapse Blender versions into the compatibility buckets we actively test.
"""
major, minor, _ = _current_version()
if major < 4:
return f"{major}.{minor}"
if major == 4 and minor < 5:
return "4.2"
if major == 4:
return "4.5"
return "5.0+"
def is_supported() -> bool:
"""Check if the detected version is at least our minimum target."""
return not is_version_less_than(*VERSION_4_2_LTS)