2025-12-09
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
# BasedPlayblast
|
||||
|
||||
**Easily create playblasts from Blender**
|
||||
|
||||
BasedPlayblast is a Blender addon that streamlines the process of creating video playblasts for animation review. It provides optimized render settings for fast preview generation while maintaining visual quality suitable for review purposes.
|
||||
|
||||
## Features
|
||||
|
||||
- **Fast Playblast Creation**: Optimized render settings for different preview modes (Solid, Material, Rendered)
|
||||
- **Multiple Display Modes**: Support for Wireframe, Solid, Material Preview, and Rendered modes
|
||||
- **Flexible Resolution**: Scene, preset, or custom resolution options
|
||||
- **Video Format Support**: MP4, MOV, AVI, MKV with various codecs (H.264, H.265, AV1, etc.)
|
||||
- **Metadata Integration**: Automatic inclusion of frame numbers, camera info, and custom notes
|
||||
- **Settings Management**: Apply and restore render settings without losing your project configuration
|
||||
- **Flamenco Support**: Custom Flamenco Job Script with a simple, non-destructive workflow
|
||||
|
||||
## Installation
|
||||
|
||||
### Via BlenderKit's Extension Repository (Recommended)
|
||||
1. Open Blender (4.2 LTS or newer)
|
||||
2. Install BlenderKit via https://www.blenderkit.com/get-blenderkit/
|
||||
3. Open Preferences (Ctrl + ,)
|
||||
4. Go to **Edit > Preferences > Get Extensions**
|
||||
5. Search for "BasedPlayblast"
|
||||
6. Click **Install**
|
||||
7. Enjoy automatic updating!
|
||||
|
||||
### Manual Installation
|
||||
1. Download the latest release, or the release that supports your intended Blender version
|
||||
2. In Blender, go to **Edit > Preferences > Add-ons**
|
||||
3. Click **Install from Disk** and select the downloaded file
|
||||
4. Enable the addon in the list
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Locate the Panel**: Go to **Properties > Output > BasedPlayblast**
|
||||
2. **Configure Settings**: Set your output path, resolution, and display mode
|
||||
3. **Create Playblast**: Click the **PLAYBLAST** button
|
||||
4. **View Result**: Click **VIEW** to open the generated video
|
||||
|
||||
- **Apply Blast Settings**: Use this button to apply optimized render settings without rendering
|
||||
- Intended particularly for Flamenco. Apply, check the resultant render settings to ensure they're correct, then send to Flamenco using the BasedPlayblast custom Job type.
|
||||
- **Restore Original Settings**: Return to your original render configuration
|
||||
- **Display Modes**:
|
||||
- **Wireframe/Solid**
|
||||
- Fast workbench viewport rendering. Recommended for short and/or locally-blasted projects.
|
||||
- **Material**
|
||||
- **Rendered**
|
||||
|
||||
## Requirements
|
||||
|
||||
- Blender 4.2 LTS or newer (validated on 4.2 LTS, 4.5 LTS, and 5.0+)
|
||||
- Python 3.x (included with Blender)
|
||||
|
||||
## Support
|
||||
|
||||
- **Documentation**: [GitHub Repository](https://github.com/RaincloudTheDragon/BasedPlayblast)
|
||||
- **Issues**: Report bugs or request features on GitHub
|
||||
- **License**: GPL-3.0-or-later
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
schema_version = "1.0.0"
|
||||
|
||||
id = "basedplayblast"
|
||||
name = "BasedPlayblast"
|
||||
tagline = "Easily create playblasts from Blender and Flamenco"
|
||||
version = "2.6.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/BasedPlayblast"
|
||||
|
||||
tags = ["Animation", "Render", "Workflow", "Video"]
|
||||
|
||||
[permissions]
|
||||
files = "Import/export files and data"
|
||||
|
||||
[build]
|
||||
paths_exclude_pattern = [
|
||||
"__pycache__/",
|
||||
"*.pyc",
|
||||
".git/",
|
||||
".github/",
|
||||
"addon_updater*",
|
||||
"basedplayblast_updater/"
|
||||
]
|
||||
@@ -0,0 +1,164 @@
|
||||
import bpy # type: ignore
|
||||
|
||||
RAINYS_EXTENSIONS_REPO_NAME = "Rainy's Extensions"
|
||||
RAINYS_EXTENSIONS_REPO_URL = (
|
||||
"https://raw.githubusercontent.com/RaincloudTheDragon/rainys-blender-extensions/refs/heads/main/index.json"
|
||||
)
|
||||
|
||||
_BOOTSTRAP_DONE = False
|
||||
|
||||
|
||||
def _log(message: str) -> None:
|
||||
print(f"RainysExtensionsCheck: {message}")
|
||||
|
||||
|
||||
def ensure_rainys_extensions_repo(_deferred: bool = False) -> None:
|
||||
"""
|
||||
Ensure the Rainy's Extensions repository is registered in Blender.
|
||||
|
||||
Safe to import and call from multiple add-ons; the helper guards against doing the
|
||||
work more than once per Blender session.
|
||||
"""
|
||||
global _BOOTSTRAP_DONE
|
||||
|
||||
if _BOOTSTRAP_DONE:
|
||||
return
|
||||
|
||||
_log("starting repository verification")
|
||||
|
||||
context_class_name = type(bpy.context).__name__
|
||||
if context_class_name == "_RestrictContext":
|
||||
if _deferred:
|
||||
_log("context still restricted after deferral; aborting repo check")
|
||||
return
|
||||
|
||||
_log("context restricted; scheduling repo check retry")
|
||||
|
||||
def _retry():
|
||||
ensure_rainys_extensions_repo(_deferred=True)
|
||||
return None
|
||||
|
||||
bpy.app.timers.register(_retry, first_interval=0.5)
|
||||
return
|
||||
|
||||
prefs = getattr(bpy.context, "preferences", None)
|
||||
if prefs is None:
|
||||
_log("no preferences available on context; skipping")
|
||||
return
|
||||
|
||||
preferences_changed = False
|
||||
addon_prefs = None
|
||||
addon_entry = None
|
||||
if hasattr(getattr(prefs, "addons", None), "get"):
|
||||
addon_entry = prefs.addons.get(__name__)
|
||||
elif hasattr(prefs, "addons"):
|
||||
try:
|
||||
addon_entry = prefs.addons[__name__]
|
||||
except Exception:
|
||||
addon_entry = None
|
||||
if addon_entry:
|
||||
addon_prefs = getattr(addon_entry, "preferences", None)
|
||||
addon_repo_initialized = bool(
|
||||
addon_prefs and getattr(addon_prefs, "repo_initialized", False)
|
||||
)
|
||||
|
||||
experimental = getattr(prefs, "experimental", None)
|
||||
if experimental and hasattr(experimental, "use_extension_platform"):
|
||||
if not experimental.use_extension_platform:
|
||||
experimental.use_extension_platform = True
|
||||
preferences_changed = True
|
||||
_log("enabled experimental extension platform")
|
||||
|
||||
repositories = None
|
||||
extensions_obj = getattr(prefs, "extensions", None)
|
||||
if extensions_obj:
|
||||
if hasattr(extensions_obj, "repos"):
|
||||
repositories = extensions_obj.repos
|
||||
elif hasattr(extensions_obj, "repositories"):
|
||||
repositories = extensions_obj.repositories
|
||||
|
||||
if repositories is None:
|
||||
filepaths = getattr(prefs, "filepaths", None)
|
||||
repositories = getattr(filepaths, "extension_repos", None) if filepaths else None
|
||||
|
||||
if repositories is None:
|
||||
_log("extension repositories collection missing; skipping")
|
||||
return
|
||||
|
||||
def _repo_matches(repo) -> bool:
|
||||
return getattr(repo, "remote_url", "") == RAINYS_EXTENSIONS_REPO_URL or getattr(
|
||||
repo, "url", ""
|
||||
) == RAINYS_EXTENSIONS_REPO_URL
|
||||
|
||||
matching_indices = [idx for idx, repo in enumerate(repositories) if _repo_matches(repo)]
|
||||
|
||||
target_repo = None
|
||||
if matching_indices:
|
||||
target_repo = repositories[matching_indices[0]]
|
||||
if len(matching_indices) > 1 and hasattr(repositories, "remove"):
|
||||
for dup_idx in reversed(matching_indices[1:]):
|
||||
try:
|
||||
repositories.remove(dup_idx)
|
||||
_log(f"removed duplicate repository entry at index {dup_idx}")
|
||||
except Exception as exc:
|
||||
_log(f"could not remove duplicate repository at index {dup_idx}: {exc}")
|
||||
else:
|
||||
target_repo = next(
|
||||
(
|
||||
repo
|
||||
for repo in repositories
|
||||
if getattr(repo, "name", "") == RAINYS_EXTENSIONS_REPO_NAME
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if target_repo is None:
|
||||
_log("repo missing; creating new entry")
|
||||
if hasattr(repositories, "new"):
|
||||
target_repo = repositories.new()
|
||||
elif hasattr(repositories, "add"):
|
||||
target_repo = repositories.add()
|
||||
else:
|
||||
_log("repository collection does not support creation; aborting")
|
||||
return
|
||||
else:
|
||||
_log("repo entry already present; validating fields")
|
||||
|
||||
changed = preferences_changed
|
||||
|
||||
def _ensure_attr(obj, attr, value):
|
||||
if hasattr(obj, attr) and getattr(obj, attr) != value:
|
||||
setattr(obj, attr, value)
|
||||
return True
|
||||
if not hasattr(obj, attr):
|
||||
_log(f"repository entry missing attribute '{attr}', skipping field")
|
||||
return False
|
||||
|
||||
changed |= _ensure_attr(target_repo, "name", RAINYS_EXTENSIONS_REPO_NAME)
|
||||
changed |= _ensure_attr(target_repo, "module", "rainys_extensions")
|
||||
changed |= _ensure_attr(target_repo, "use_remote_url", True)
|
||||
changed |= _ensure_attr(target_repo, "remote_url", RAINYS_EXTENSIONS_REPO_URL)
|
||||
changed |= _ensure_attr(target_repo, "use_sync_on_startup", True)
|
||||
changed |= _ensure_attr(target_repo, "use_cache", True)
|
||||
changed |= _ensure_attr(target_repo, "use_access_token", False)
|
||||
|
||||
if addon_prefs and hasattr(addon_prefs, "repo_initialized") and not addon_prefs.repo_initialized:
|
||||
addon_prefs.repo_initialized = True
|
||||
changed = True
|
||||
|
||||
if not changed:
|
||||
_log("repository already configured; skipping preference save")
|
||||
_BOOTSTRAP_DONE = True
|
||||
return
|
||||
|
||||
if hasattr(bpy.ops, "wm") and hasattr(bpy.ops.wm, "save_userpref"):
|
||||
try:
|
||||
bpy.ops.wm.save_userpref()
|
||||
_log("preferences updated and saved")
|
||||
except Exception as exc: # pragma: no cover
|
||||
print(f"RainysExtensionsCheck: could not save preferences after repo update -> {exc}")
|
||||
else:
|
||||
_log("preferences API unavailable; changes not persisted")
|
||||
|
||||
_BOOTSTRAP_DONE = True
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user