2026-02-16

This commit is contained in:
2026-03-17 15:25:32 -06:00
parent d5dd373de0
commit 60100fbab2
560 changed files with 33397 additions and 20776 deletions
@@ -0,0 +1,96 @@
## [v0.0.7] - 2026-02-12
### Fixed
- Pack: enable autopack before pack_all; force-load images and run pack_all twice; pack remaining images so textures are embedded (fixes "Failed to create GPU texture from Blender image" when rendering headless).
- Remap: print actual paths in warnings (not placeholders); normalized path lookup and reverse copy_map so library blend image paths resolve.
- pack_linked: catch PermissionError on library path checks so inaccessible (e.g. NAS) libs dont abort; remove missing/inaccessible library refs from blend before save.
---
## [v0.0.6] - 2026-01-27
### Fixed
- Config import in `utils.compat`: use `from .. import config` (config is at addon root)
- Output panel: no longer write to scene in draw(); Blender 5.0 forbids ID writes in draw; operators already fall back to prefs when output_path empty
---
## [v0.0.5] - 2026-01-30
### Added
- Project size limit (GB) in Output panel: per-pack int (0 = no limit, default 2), max 32-bit int
### Fixed
- USD/cache file paths remapped: `bpy.data.cache_files[].filepath` remapped to packed location; .usd/.usdc/.usda added to copy_map
---
## [v0.0.4] - 2026-01-27
### Added
- ZIP pack: option to exclude video and audio files from archive
- Default output path in preferences
- NLA enable for animation layers (moved to UI panel; only runs on objects with anim layers)
### Fixed
- Physics/point cache included in ZIP pack (robocopy fallback when Python copy fails on network paths)
- Cache truncated to frame range (Blender bphys `name_frame_index` naming; safeguard if no files match)
- External cache paths remapped to relative (cache dirs in copy_map; prefix matching in remap script)
- Frame range applied only to top-level target blend, not dependent blends
- Recursion issue in all three pack ops; send-current-blend path handling
- Removed packed-suffix behavior
---
## [v0.0.3] - 2026-01-22
### Changed
- **Removed all website functionality** per SheepIt developer request
- Operators now save packed files to user-specified locations instead of uploading
- All authentication and website interaction code has been removed
- Users must manually upload and configure projects on the SheepIt website
---
## [v0.0.2] - 2026-01-22
### Fixed
- Fixed Blender extension policy violations related to `batter.asset_usage` module import
- Removed `sys.path` manipulation to comply with Blender extension policies
- Changed from top-level module import to submodule import (registered as `ops._asset_usage`)
- Fixed `dataclasses` `__module__` resolution issue when loading modules via `importlib`
### Internal
- Refactored `batter.asset_usage` import to use `importlib` without violating extension policies
- Module now properly registered in `sys.modules` as a submodule before execution
---
## [v0.0.1] - 2026-01-21
### Features
- Initial release of SheepIt Project Submitter
- Three submission workflows:
- Submit Current: Direct submission of current blend file
- Submit as ZIP: Automatic asset packing with ZIP archive creation
- Submit as Packed Blend: Automatic asset packing directly into blend file
- Frame range configuration (full range or custom)
- Automatic asset packing for linked blend files, textures, images, and videos
- Cache truncation to match selected frame range
- Real-time progress tracking with cancellable operations
- File size validation (2GB limit) with optimization suggestions
- Automatic path remapping for all asset types
- Missing file detection and reporting
- Oversized file detection (>2GB linked files)
- Automatic backup file cleanup (`.blend1` through `.blend32`)
- Compressed blend file saves
- Username/password authentication
- Browser redirect to project configuration page after submission
- Works with unsaved blend files (operates on in-memory state)
### Internal
- Based on asset usage detection from Batter project
- Modal operator architecture for responsive UI
- Incremental packing system for large projects
- Subprocess-based asset processing for stability
- Comprehensive error handling and user feedback
@@ -0,0 +1,53 @@
# SheepIt Project Submitter
A Blender addon for packing projects for the SheepIt render farm with automatic asset packing and intelligent workflow management.
## Features
| Automatic Asset Packing | Frame Range Control | Multiple Packing Methods |
|--|--|--|
| Automatically packs all linked blend files, textures, images, and external assets into your project. Supports both ZIP and packed blend file workflows. | Configure custom frame ranges directly in Blender without saving your file. Frame ranges are automatically applied to packed files. | Pack as current blend file, packed ZIP archive, or packed blend file. Choose the method that best fits your project. |
| Cache Management | Size Validation | Progress Tracking |
|--|--|--|
| Automatically truncates cache files to match your selected frame range, reducing file sizes significantly. | Validates file sizes before packing (2GB limit) with helpful suggestions for optimization. | Real-time progress bars and status messages for all operations. All steps are cancellable. |
| Path Remapping | Missing File Detection | Error Reporting |
|--|--|--|
| Intelligently remaps all asset paths to work correctly on the render farm. Handles textures, images, videos, and linked blend files. | Detects and reports missing linked files and oversized files (>2GB) that cannot be packed. | Comprehensive error messages with actionable suggestions for resolving issues. |
### Additional Features:
- Works with unsaved blend files (operates on in-memory state)
- Automatic backup file cleanup (`.blend1` through `.blend32`)
- Compressed blend file saves for optimal file sizes
- File browser for selecting output location
## Installation
1. Download the latest release from [GitHub Releases](https://github.com/RaincloudTheDragon/sheepit-project-submitter/releases)
2. In Blender, go to `Edit > Preferences > Add-ons`
3. Click `Install...` and select the downloaded ZIP file
4. Enable the addon by checking the box next to "SheepIt Project Submitter"
## Usage
1. **Set Frame Range**: In the Output properties panel, configure your frame range (full range or custom)
2. **Pack Project**: Choose your packing method:
- **Pack Current Blend**: Saves the current blend file with frame range applied
- **Pack as ZIP**: Packs all assets and creates a ZIP archive (recommended for scenes with caches)
- **Pack as Blend**: Packs all assets directly into the blend file
3. **Select Output Location**: A file browser will open to select where to save the packed file
4. **Upload Manually**: Upload the packed file to SheepIt via the website and configure your project settings
## Requirements
- Blender 3.0.0 or later
## License
GPL-3.0-or-later
## Links
- **GitHub Repository**: [https://github.com/RaincloudTheDragon/sheepit-project-submitter](https://github.com/RaincloudTheDragon/sheepit-project-submitter)
- **SheepIt Render Farm**: [https://www.sheepit-renderfarm.com](https://www.sheepit-renderfarm.com)
@@ -0,0 +1,188 @@
"""
SheepIt Project Submitter Addon
A Blender addon for submitting projects to SheepIt render farm with automatic asset packing.
"""
import bpy
from bpy.utils import register_class
from .utils import compat
from . import ops
from . import ui
from . import rainys_repo_bootstrap
def _update_output_path(self, context):
"""Update callback for output_path property - auto-populates from preferences if empty."""
if not self.output_path:
from .utils.compat import get_addon_prefs
prefs = get_addon_prefs()
if prefs and prefs.default_output_path:
self.output_path = prefs.default_output_path
# SheepIt Submit Settings Property Group
class SHEEPIT_PG_submit_settings(bpy.types.PropertyGroup):
"""Property group for storing submit settings."""
# Frame range mode
frame_range_mode: bpy.props.EnumProperty(
name="Frame Range Mode",
description="Choose between full range or custom frame range",
items=[
('FULL', "Full Range", "Use the full frame range from scene settings"),
('CUSTOM', "Custom", "Specify custom start, end, and step frames"),
],
default='FULL',
)
# Custom frame range
frame_start: bpy.props.IntProperty(
name="Start Frame",
description="Start frame for rendering",
default=1,
min=0,
)
frame_end: bpy.props.IntProperty(
name="End Frame",
description="End frame for rendering",
default=250,
min=0,
)
frame_step: bpy.props.IntProperty(
name="Frame Step",
description="Frame step (render every Nth frame)",
default=1,
min=1,
)
# Compute method
compute_method: bpy.props.EnumProperty(
name="Compute Method",
description="Choose CPU or GPU rendering",
items=[
('CPU', "CPU", "Use CPU for rendering"),
('GPU', "GPU", "Use GPU for rendering"),
],
default='CPU',
)
# Checkboxes
renderable_by_all: bpy.props.BoolProperty(
name="Renderable by all members",
description="Allow all SheepIt members to render this project",
default=True,
)
generate_mp4: bpy.props.BoolProperty(
name="Generate MP4 video",
description="Generate MP4 video from rendered frames",
default=False,
)
# Advanced options
memory_used_mb: bpy.props.StringProperty(
name="Memory used (MB)",
description="Memory limit in MB (leave empty for default)",
default="",
)
# Advanced options visibility
show_advanced: bpy.props.BoolProperty(
name="Show Advanced Options",
description="Show advanced submission options",
default=False,
)
# ZIP pack: exclude video/audio files
exclude_video_from_zip: bpy.props.BoolProperty(
name="Exclude video/audio from ZIP",
description="Exclude video and audio files (e.g. mp4, avi, mov, wav, mp3) from the ZIP pack",
default=False,
)
# Project size limit (GB); 0 = no limit (max 32-bit signed int for Blender C API)
project_size_limit_gb: bpy.props.IntProperty(
name="Project Size Limit (GB)",
description="Maximum project/ZIP/blend size in GB (0 = no limit)",
default=2,
min=0,
max=(1 << 31) - 1,
)
# Pack output path (set by pack operators)
pack_output_path: bpy.props.StringProperty(
name="Pack Output Path",
description="Path to the packed output directory",
default="",
)
output_file_path: bpy.props.StringProperty(
name="Output File Path",
description="Path where the packed file (ZIP or blend) will be saved",
default="",
subtype='FILE_PATH',
)
output_path: bpy.props.StringProperty(
name="Output Path",
description="Directory path where packed files will be saved",
default="",
subtype='DIR_PATH',
update=_update_output_path,
)
# Progress tracking for submission operations
is_submitting: bpy.props.BoolProperty(
name="Is Submitting",
description="Whether a submission is currently in progress",
default=False,
)
submit_progress: bpy.props.FloatProperty(
name="Submit Progress",
description="Progress percentage for submission operations",
default=0.0,
min=0.0,
max=100.0,
subtype='PERCENTAGE',
)
submit_status_message: bpy.props.StringProperty(
name="Submit Status Message",
description="Current status message for submission operations",
default="",
)
def register():
"""Register the addon."""
from .utils import compat
compat.safe_register_class(SHEEPIT_PG_submit_settings)
bpy.types.Scene.sheepit_submit = bpy.props.PointerProperty(type=SHEEPIT_PG_submit_settings)
# Register operators and UI (preferences are registered in ui.register())
ops.register()
ui.register()
# Bootstrap Rainy's Extensions repository
rainys_repo_bootstrap.register()
def unregister():
"""Unregister the addon."""
from .utils import compat
# Bootstrap unregistration
rainys_repo_bootstrap.unregister()
# Unregister operators and UI
ui.unregister()
ops.unregister()
compat.safe_unregister_class(SHEEPIT_PG_submit_settings)
if hasattr(bpy.types.Scene, 'sheepit_submit'):
del bpy.types.Scene.sheepit_submit
@@ -0,0 +1,213 @@
from __future__ import annotations
import dataclasses
import functools
from collections import defaultdict
from pathlib import Path
import bpy
from bpy.types import Library, ID
def find() -> dict[Library | None, set[AssetUsage]]:
"""Return a mapping from each blend file to the assets it uses.
The None key indicates the direct dependencies of the currently-open blend file.
"""
_blend_asset_usage = find_blend_asset_usage()
_nonblend_asset_usage = find_nonblend_asset_usage()
return _merge_keys(_blend_asset_usage, _nonblend_asset_usage)
@dataclasses.dataclass
class AssetUsage:
"""The usage of an asset by a specific blend file."""
abspath: Path
"""Absolute path to the asset."""
# user: Path
# """Absolute path of whatever blend file uses this Asset."""
reference_path: str
"""The path by which this asset is referenced.
This is tracked so that the search & replace operation for path rewriting
knows what to search for.
NOTE: the above may not be true, as paths to assets from a library blend
file may already be rewritten by Blender upon loading; this process ensures
that all relative paths are relative to the main blend file, and so they may
not be the same in the library itself.
"""
is_blendfile: bool
"""Whether this asset is a blend file or not.
Blend files can refer to other assets.
"""
def __hash__(self) -> int:
return hash((self.abspath, self.reference_path, self.is_blendfile))
def __eq__(self, value: object) -> bool:
if not isinstance(value, AssetUsage):
return False
return (
self.abspath,
self.reference_path,
self.is_blendfile,
) == (
value.abspath,
value.reference_path,
value.is_blendfile,
)
@functools.lru_cache
def library_abspath(lib: Library | None) -> Path:
"""Return the absolute path to the library.
lib=None returns the absolute path of the current blend file.
"""
if lib is None:
filepath = bpy.data.filepath
else:
filepath = bpy.path.abspath(lib.filepath)
return Path(filepath).resolve()
def find_blend_asset_usage() -> dict[Library | None, set[AssetUsage]]:
"""Return a mapping from each blend file to the blend files it uses as libraries."""
# Find all dependencies between libraries.
# Keys: Library ID (or `None` for current blendfile)
# Values: Libraries used by the key one.
libs_deps: dict[Library | None, set[AssetUsage]] = defaultdict(set)
for id, id_users in bpy.data.user_map().items():
id_lib = id.library
libs_deps.setdefault(id_lib, set())
for id_user in id_users:
if id_user.library == id_lib:
continue
asset_usage = AssetUsage(
abspath=library_abspath(id_lib),
reference_path=id_lib.filepath,
is_blendfile=True,
)
libs_deps[id_user.library].add(asset_usage)
return dict(libs_deps)
def find_nonblend_asset_usage() -> dict[Library | None, set[AssetUsage]]:
"""Return a mapping from a blend file to the non-blend asset files it uses."""
file_path_map = bpy.data.file_path_map(include_libraries=False)
asset_usages: dict[Library | None, set[AssetUsage]] = defaultdict(set)
for asset_user, filepaths in file_path_map.items():
if not filepaths:
continue
if isinstance(asset_user, Library):
continue
assert isinstance(asset_user, ID)
lib = asset_user.library
if lib and lib.packed_file:
raise RuntimeError(f"Batter does not support packed libraries (yet): {lib}")
for filepath in filepaths:
abspath = Path(bpy.path.abspath(filepath, library=lib)).resolve()
asset_usage = AssetUsage(
abspath=abspath,
reference_path=filepath,
is_blendfile=False,
)
asset_usages[lib].add(asset_usage)
# Also check bpy.data.images directly, as file_path_map may miss some image references
# (e.g., PNG, EXR files in certain node setups)
for img in bpy.data.images:
if img.packed_file:
continue
if not img.filepath:
continue
# Skip generated images and sequences/movies
if getattr(img, 'source', 'FILE') not in {'FILE', 'TILED'}:
continue
lib = getattr(img, 'library', None)
if lib and lib.packed_file:
raise RuntimeError(f"Batter does not support packed libraries (yet): {lib}")
try:
abspath = Path(bpy.path.abspath(img.filepath, library=lib)).resolve()
asset_usage = AssetUsage(
abspath=abspath,
reference_path=img.filepath,
is_blendfile=False,
)
asset_usages[lib].add(asset_usage)
except Exception:
pass
# Also check sounds and movie clips for completeness
for snd in getattr(bpy.data, 'sounds', []):
if snd.packed_file:
continue
if not snd.filepath:
continue
lib = getattr(snd, 'library', None)
if lib and lib.packed_file:
raise RuntimeError(f"Batter does not support packed libraries (yet): {lib}")
try:
abspath = Path(bpy.path.abspath(snd.filepath, library=lib)).resolve()
asset_usage = AssetUsage(
abspath=abspath,
reference_path=snd.filepath,
is_blendfile=False,
)
asset_usages[lib].add(asset_usage)
except Exception:
pass
for mc in getattr(bpy.data, 'movieclips', []):
if not mc.filepath:
continue
lib = getattr(mc, 'library', None)
if lib and lib.packed_file:
raise RuntimeError(f"Batter does not support packed libraries (yet): {lib}")
try:
abspath = Path(bpy.path.abspath(mc.filepath, library=lib)).resolve()
asset_usage = AssetUsage(
abspath=abspath,
reference_path=mc.filepath,
is_blendfile=False,
)
asset_usages[lib].add(asset_usage)
except Exception:
pass
return dict(asset_usages)
def _merge_keys(
a: dict[Library | None, set[AssetUsage]],
b: dict[Library | None, set[AssetUsage]],
) -> dict[Library | None, set[AssetUsage]]:
merged = defaultdict(set)
for key, values in a.items():
merged[key].update(values)
for key, values in b.items():
merged[key].update(values)
return merged
@@ -0,0 +1,18 @@
schema_version = "1.0.0"
id = "sheepit_project_submitter"
name = "SheepIt Project Submitter"
version = "0.0.7"
type = "add-on"
author = "RaincloudTheDragon"
maintainer = "RaincloudTheDragon"
blender_version_min = "3.0.0"
license = ["GPL-3.0-or-later"]
description = "Submit Blender projects to SheepIt render farm with automatic asset packing."
homepage = "https://github.com/RaincloudTheDragon/sheepit-project-submitter/"
tagline = "Submit projects to SheepIt render farm"
tags = ["render", "farm", "submission", "utility"]
# Python modules to load for this add-on
modules = ["sheepit_project_submitter"]
@@ -0,0 +1,20 @@
"""
Configuration constants for SheepIt Project Submitter addon.
"""
# Addon metadata
ADDON_NAME = "SheepIt Project Submitter"
ADDON_ID = "sheepit_project_submitter"
# SheepIt API endpoints (to be researched and updated)
SHEEPIT_API_BASE = "https://www.sheepit-renderfarm.com"
SHEEPIT_CLIENT_BASE = "https://client.sheepit-renderfarm.com"
# Debug mode
DEBUG = False
def debug_print(message: str) -> None:
"""Print debug message if DEBUG is enabled."""
if DEBUG:
print(f"[{ADDON_NAME}] {message}")
@@ -0,0 +1,24 @@
"""
Operators for SheepIt Project Submitter addon.
"""
def register():
"""Register all operators."""
# Lazy imports - these are only executed when register() is called
# This avoids circular import issues since imports happen at function call time
from . import pack_ops
from . import submit_ops
pack_ops.register()
submit_ops.register()
def unregister():
"""Unregister all operators."""
# Lazy imports - these are only executed when unregister() is called
from . import pack_ops
from . import submit_ops
submit_ops.unregister()
pack_ops.unregister()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,460 @@
"""
Submission operations for SheepIt render farm.
"""
import os
import zipfile
import tempfile
import subprocess
from pathlib import Path
from typing import Optional, Tuple
import bpy
from bpy.types import Operator
from .. import config
def apply_frame_range_to_blend(blend_path: Path, frame_start: int, frame_end: int, frame_step: int) -> None:
"""
Apply frame range settings to a blend file using subprocess.
Args:
blend_path: Path to the blend file to modify
frame_start: Start frame value
frame_end: End frame value
frame_step: Frame step value
"""
script = f"""
import bpy
for scene in bpy.data.scenes:
scene.frame_start = {frame_start}
scene.frame_end = {frame_end}
scene.frame_step = {frame_step}
bpy.ops.wm.save_mainfile(compress=True)
print(f'Applied frame range {frame_start}-{frame_end} (step {frame_step}) to all scenes')
"""
result = subprocess.run([
"blender", "--factory-startup", "-b", str(blend_path), "--python-expr", script
], capture_output=True, text=True, check=False)
if result.returncode != 0:
print(f"[SheepIt Submit] WARNING: Failed to apply frame range to {blend_path.name}")
if result.stderr:
print(f"[SheepIt Submit] Error: {result.stderr[:200]}")
else:
print(f"[SheepIt Submit] Applied frame range {frame_start}-{frame_end} (step {frame_step}) to {blend_path.name}")
def save_current_blend_with_frame_range(submit_settings, temp_dir: Optional[Path] = None) -> Tuple[Path, int, int, int]:
"""
Save current blend state to a temporary file and apply frame range from submit_settings.
Args:
submit_settings: Submit settings containing frame range configuration
temp_dir: Optional temporary directory (if None, creates a new one)
Returns:
Tuple of (temp_blend_path, frame_start, frame_end, frame_step)
"""
# Determine frame range from submit_settings
if submit_settings.frame_range_mode == 'FULL':
frame_start = bpy.context.scene.frame_start
frame_end = bpy.context.scene.frame_end
frame_step = bpy.context.scene.frame_step
else:
frame_start = submit_settings.frame_start
frame_end = submit_settings.frame_end
frame_step = submit_settings.frame_step
# Create temp directory if not provided
if temp_dir is None:
temp_dir = Path(tempfile.mkdtemp(prefix="sheepit_submit_"))
# Generate temp blend filename
blend_name = bpy.data.filepath if bpy.data.filepath else "untitled"
blend_name = Path(blend_name).stem if blend_name else "untitled"
temp_blend = temp_dir / f"{blend_name}.blend"
print(f"[SheepIt Submit] Saving current blend state to: {temp_blend}")
print(f"[SheepIt Submit] Frame range: {frame_start} - {frame_end} (step: {frame_step})")
# Save current blend state
try:
temp_dir.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(temp_blend), copy=True, compress=True)
print(f"[SheepIt Submit] Saved current blend state to temp file")
except Exception as e:
error_msg = f"Failed to save current blend state: {type(e).__name__}: {str(e)}"
print(f"[SheepIt Submit] ERROR: {error_msg}")
raise RuntimeError(error_msg) from e
# Apply frame range to the saved file
apply_frame_range_to_blend(temp_blend, frame_start, frame_end, frame_step)
return temp_blend, frame_start, frame_end, frame_step
class SHEEPIT_OT_submit_current(Operator):
"""Pack current blend file to output location without packing assets."""
bl_idname = "sheepit.submit_current"
bl_label = "Pack Current Blend"
bl_description = "Save the current blend file with frame range applied to the specified output location"
bl_options = {'REGISTER', 'UNDO'}
def invoke(self, context, event):
"""Start the packing operation."""
submit_settings = context.scene.sheepit_submit
# Check if already packing
if submit_settings.is_submitting:
self.report({'WARNING'}, "A packing operation is already in progress.")
return {'CANCELLED'}
# Get output path from settings or preferences
output_dir = submit_settings.output_path
if not output_dir:
from ..utils.compat import get_addon_prefs
prefs = get_addon_prefs()
if prefs and prefs.default_output_path:
output_dir = prefs.default_output_path
submit_settings.output_path = output_dir
if not output_dir:
self.report({'ERROR'}, "Please specify an output path in the panel below.")
return {'CANCELLED'}
# Generate filename
blend_name = bpy.data.filepath if bpy.data.filepath else "untitled"
if blend_name:
blend_name = Path(blend_name).stem
else:
blend_name = "untitled"
output_file = Path(output_dir) / f"{blend_name}.blend"
# Initialize progress properties
submit_settings.is_submitting = True
submit_settings.submit_progress = 0.0
submit_settings.submit_status_message = "Initializing..."
# Initialize phase tracking
self._phase = 'INIT'
self._output_path = output_file
self._temp_blend_path = None
self._temp_dir = None
self._success = False
self._message = ""
self._error = None
# Create timer for modal updates
self._timer = context.window_manager.event_timer_add(0.1, window=context.window)
# Force UI redraw
for area in context.screen.areas:
if area.type == 'PROPERTIES':
area.tag_redraw()
# Start modal operation
context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'}
def execute(self, context):
"""Legacy execute method - redirects to invoke for modal operation."""
return self.invoke(context, None)
def modal(self, context, event):
"""Handle modal events and update progress."""
submit_settings = context.scene.sheepit_submit
# Handle ESC key to cancel
if event.type == 'ESC':
self._cleanup(context, cancelled=True)
self.report({'INFO'}, "Packing cancelled.")
return {'CANCELLED'}
# Handle timer events
if event.type == 'TIMER':
try:
if self._phase == 'INIT':
submit_settings.submit_progress = 0.0
submit_settings.submit_status_message = "Initializing..."
self._phase = 'SAVING_BLEND'
return {'RUNNING_MODAL'}
elif self._phase == 'SAVING_BLEND':
submit_settings.submit_progress = 10.0
submit_settings.submit_status_message = "Saving current blend state..."
# Save current blend state to temp file with frame range applied
try:
self._temp_blend_path, frame_start, frame_end, frame_step = save_current_blend_with_frame_range(submit_settings)
self._temp_dir = self._temp_blend_path.parent
print(f"[SheepIt Submit] Using temp blend file: {self._temp_blend_path}")
except Exception as e:
self._error = f"Failed to save current blend state: {str(e)}"
self._cleanup(context, cancelled=True)
self.report({'ERROR'}, self._error)
return {'CANCELLED'}
self._phase = 'APPLYING_FRAME_RANGE'
return {'RUNNING_MODAL'}
elif self._phase == 'APPLYING_FRAME_RANGE':
submit_settings.submit_progress = 20.0
submit_settings.submit_status_message = "Frame range applied."
# Frame range is already applied in save_current_blend_with_frame_range
self._phase = 'VALIDATING_FILE_SIZE'
return {'RUNNING_MODAL'}
elif self._phase == 'VALIDATING_FILE_SIZE':
submit_settings.submit_progress = 30.0
submit_settings.submit_status_message = "Validating file size..."
# Check blend file size
if self._temp_blend_path and self._temp_blend_path.exists():
blend_size = self._temp_blend_path.stat().st_size
blend_size_gb = blend_size / (1024 * 1024 * 1024)
from .pack_ops import _get_project_size_limit_bytes
max_bytes = _get_project_size_limit_bytes(context)
if max_bytes is not None and blend_size > max_bytes:
limit_gb = max_bytes / (1024 * 1024 * 1024)
print(f"[SheepIt Pack] Blend file size: {blend_size_gb:.2f} GB")
error_msg = (
f"Blend file size ({blend_size_gb:.2f} GB) exceeds project limit ({limit_gb:.1f} GB).\n\n"
"To reduce file size, consider:\n"
"- Optimizing the scene (reduce geometry, simplify materials)\n"
"- Optimizing asset files (compress textures, reduce resolution)\n"
"- Splitting the frame range (render in smaller chunks)"
)
print(f"[SheepIt Pack] ERROR: {error_msg}")
self._error = error_msg
self._cleanup(context, cancelled=True)
self.report({'ERROR'}, self._error)
return {'CANCELLED'}
self._phase = 'SAVING_FILE'
return {'RUNNING_MODAL'}
elif self._phase == 'SAVING_FILE':
submit_settings.submit_progress = 50.0
submit_settings.submit_status_message = "Saving file to output location..."
try:
# Ensure output directory exists
self._output_path.parent.mkdir(parents=True, exist_ok=True)
# Copy temp file to output location
import shutil
shutil.copy2(self._temp_blend_path, self._output_path)
print(f"[SheepIt Pack] Saved blend file to: {self._output_path}")
self._success = True
self._message = f"Blend file saved to: {self._output_path}"
submit_settings.submit_progress = 90.0
submit_settings.submit_status_message = "File saved successfully!"
self._phase = 'CLEANUP'
except Exception as e:
self._error = f"Failed to save file: {str(e)}"
self._cleanup(context, cancelled=True)
self.report({'ERROR'}, self._error)
return {'CANCELLED'}
return {'RUNNING_MODAL'}
elif self._phase == 'CLEANUP':
submit_settings.submit_progress = 98.0
submit_settings.submit_status_message = "Cleaning up..."
# Clean up temp file on success
if self._temp_blend_path and self._temp_blend_path.exists():
try:
self._temp_blend_path.unlink()
if self._temp_dir and self._temp_dir.exists():
try:
self._temp_dir.rmdir()
except Exception:
pass # Directory may not be empty
print(f"[SheepIt Submit] Cleaned up temp file: {self._temp_blend_path}")
except Exception as e:
print(f"[SheepIt Submit] WARNING: Could not clean up temp file: {e}")
self._phase = 'COMPLETE'
return {'RUNNING_MODAL'}
elif self._phase == 'COMPLETE':
submit_settings.submit_progress = 100.0
submit_settings.submit_status_message = "Packing complete!"
# Small delay to show completion
import time
time.sleep(0.2)
self._cleanup(context, cancelled=False)
self.report({'INFO'}, f"Blend file saved to: {self._output_path}")
return {'FINISHED'}
except Exception as e:
import traceback
traceback.print_exc()
self._error = f"Packing failed: {type(e).__name__}: {str(e)}"
self._cleanup(context, cancelled=True)
self.report({'ERROR'}, self._error)
return {'CANCELLED'}
return {'RUNNING_MODAL'}
def _cleanup(self, context, cancelled=False):
"""Clean up progress properties and timer."""
submit_settings = context.scene.sheepit_submit
# Remove timer
if hasattr(self, '_timer') and self._timer:
context.window_manager.event_timer_remove(self._timer)
# Reset progress properties
submit_settings.is_submitting = False
submit_settings.submit_progress = 0.0
if cancelled and self._error:
submit_settings.submit_status_message = self._error
else:
submit_settings.submit_status_message = ""
# Force UI redraw
for area in context.screen.areas:
if area.type == 'PROPERTIES':
area.tag_redraw()
def execute(self, context):
"""Execute method - starts the modal operation."""
# This is called by invoke, so we don't redirect back to avoid recursion
# The actual implementation is in the invoke method above
pass
# Video and audio extensions to exclude when exclude_video=True
_MEDIA_EXTENSIONS = frozenset({
'.mp4', '.avi', '.mov', '.mkv', '.webm', '.m4v', '.wmv', '.flv', '.ogv', '.mpg', '.mpeg', '.m2v',
'.wav', '.mp3', '.ogg', '.flac', '.aac', '.m4a', '.wma', '.opus', '.aiff', '.aif',
})
def create_zip_from_directory(directory: Path, output_zip: Path, progress_callback=None, cancel_check=None, exclude_video: bool = False) -> None:
"""Create a ZIP file from a directory.
Args:
directory: Directory to zip
output_zip: Output ZIP file path
progress_callback: Optional callback(progress_pct, message) for progress updates
cancel_check: Optional callback() -> bool to check for cancellation
exclude_video: If True, skip common video and audio file extensions
"""
import time
print(f"[SheepIt Submit] Starting ZIP creation...")
print(f"[SheepIt Submit] Directory: {directory}")
print(f"[SheepIt Submit] Output: {output_zip}")
# Delete .blend1 through .blend32 backup files before zipping
if progress_callback:
progress_callback(0.0, "Removing backup files...")
backup_files = []
for i in range(1, 33): # blend1 through blend32
pattern = f"*.blend{i}"
backup_files.extend(directory.rglob(pattern))
if backup_files:
print(f"[SheepIt Submit] Found {len(backup_files)} backup files (.blend1-.blend32), deleting...")
deleted_count = 0
for backup_file in backup_files:
try:
backup_file.unlink()
deleted_count += 1
except Exception as e:
print(f"[SheepIt Submit] WARNING: Could not delete {backup_file.name}: {e}")
print(f"[SheepIt Submit] Deleted {deleted_count}/{len(backup_files)} backup files")
else:
print(f"[SheepIt Submit] No backup files (.blend1-.blend32) found")
if progress_callback:
progress_callback(0.0, "Counting files...")
# Collect all dirs (for empty-dir entries) and files
dir_arcs = set()
file_list = []
file_count = 0
total_size = 0
for root, dirs, files in os.walk(directory):
root_path = Path(root)
for d in dirs:
dir_arcs.add(root_path.joinpath(d).relative_to(directory))
for file in files:
file_path = root_path / file
if not file_path.exists():
continue
if exclude_video and file_path.suffix.lower() in _MEDIA_EXTENSIONS:
continue
file_count += 1
total_size += file_path.stat().st_size
file_list.append((file_path, file_path.relative_to(directory)))
print(f"[SheepIt Submit] Found {file_count} files, total size: {total_size / (1024*1024):.2f} MB")
print(f"[SheepIt Submit] Creating ZIP (this may take a while)...")
if progress_callback:
progress_callback(1.0, f"Creating ZIP archive ({file_count} files, {total_size / (1024*1024):.1f} MB)...")
start_time = time.time()
files_added = 0
with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_STORED) as zipf:
for dir_arc in sorted(dir_arcs):
arcname = str(dir_arc).replace("\\", "/") + "/"
zipf.writestr(arcname, "")
for file_path, arcname in file_list:
if cancel_check and cancel_check():
raise InterruptedError("ZIP creation cancelled by user")
if not file_path.exists():
continue
try:
zipf.write(file_path, arcname)
files_added += 1
# Progress updates - more frequent for large files
if files_added == 1:
print(f"[SheepIt Submit] Adding files to ZIP...")
if progress_callback:
progress_callback(2.0, f"Adding files to ZIP... (1/{file_count})")
elif files_added % 10 == 0 or (file_count > 0 and files_added % max(1, file_count // 100) == 0):
elapsed = time.time() - start_time
rate = files_added / elapsed if elapsed > 0 else 0
progress_pct = 2.0 + (files_added / file_count * 93.0) if file_count > 0 else 2.0
print(f"[SheepIt Submit] Progress: {files_added}/{file_count} files ({files_added*100//file_count}%), {rate:.1f} files/sec")
if progress_callback:
progress_callback(progress_pct, f"Creating ZIP... ({files_added}/{file_count} files, {rate:.1f} files/sec)")
except Exception as e:
print(f"[SheepIt Submit] WARNING: Failed to add {arcname}: {type(e).__name__}: {str(e)}")
elapsed = time.time() - start_time
print(f"[SheepIt Submit] ZIP creation completed!")
print(f"[SheepIt Submit] Files added: {files_added}/{file_count}")
print(f"[SheepIt Submit] Time taken: {elapsed:.2f} seconds")
if elapsed > 0:
print(f"[SheepIt Submit] Average rate: {files_added/elapsed:.1f} files/sec")
if progress_callback:
progress_callback(100.0, "ZIP archive created")
def register():
"""Register operators."""
bpy.utils.register_class(SHEEPIT_OT_submit_current)
def unregister():
"""Unregister operators."""
bpy.utils.unregister_class(SHEEPIT_OT_submit_current)
@@ -0,0 +1,174 @@
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
def register() -> None:
"""Entry point for Blender add-on registration."""
ensure_rainys_extensions_repo()
def unregister() -> None:
"""Reset bootstrap guard so next registration re-runs the checks."""
global _BOOTSTRAP_DONE
_BOOTSTRAP_DONE = False
@@ -0,0 +1,23 @@
"""
UI modules for SheepIt Project Submitter addon.
"""
from . import output_panel
from . import preferences_ui
__all__ = ["output_panel", "preferences_ui"]
def register():
"""Register all UI classes."""
# Register preferences first so we can access variables in config.py
preferences_ui.register()
# Register other UI components
output_panel.register()
def unregister():
"""Unregister all UI classes."""
output_panel.unregister()
preferences_ui.unregister()
@@ -0,0 +1,90 @@
"""
Output panel UI for SheepIt Project Submitter.
Located in the Output tab, similar to Flamenco addon.
"""
import bpy
from bpy.types import Panel
from ..utils import compat
class SHEEPIT_PT_output_panel(Panel):
"""SheepIt submission panel in Output properties."""
bl_label = "SheepIt Render Farm"
bl_idname = "SHEEPIT_PT_output_panel"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = "output"
def draw(self, context):
layout = self.layout
scene = context.scene
submit_settings = scene.sheepit_submit
# Do not write to scene in draw(); operators fall back to prefs.default_output_path when output_path is empty.
# Animation Setup Section
box = layout.box()
box.label(text="Animation Setup:", icon='ACTION')
col = box.column()
col.operator("sheepit.enable_nla", text="Disable Animation Layers", icon='ACTION')
layout.separator()
# Frame Range Section
box = layout.box()
box.label(text="Frame Range:", icon='RENDER_ANIMATION')
row = box.row()
row.prop(submit_settings, "frame_range_mode", expand=True)
if submit_settings.frame_range_mode == 'CUSTOM':
col = box.column(align=True)
col.prop(submit_settings, "frame_start")
col.prop(submit_settings, "frame_end")
col.prop(submit_settings, "frame_step")
else:
# Show current scene frame range
row = box.row()
row.label(text=f"Scene Range: {scene.frame_start} - {scene.frame_end} (Step: {scene.frame_step})")
layout.separator()
# Progress Bar Section (when submitting)
if submit_settings.is_submitting:
box = layout.box()
box.label(text=submit_settings.submit_status_message, icon='TIME')
box.prop(submit_settings, "submit_progress", text="Progress", slider=True)
layout.separator()
# Packing Buttons
col = layout.column()
col.scale_y = 1.5
# Pack Current Blend button (first)
op = col.operator("sheepit.submit_current", text="Pack Current Blend", icon='EXPORT')
op = col.operator("sheepit.pack_zip", text="Pack as ZIP (for scenes with caches)", icon='PACKAGE')
row = layout.row()
row.prop(submit_settings, "exclude_video_from_zip", text="Exclude video/audio from ZIP")
# Pack as Blend button
op = col.operator("sheepit.pack_blend", text="Pack as Blend", icon='FILE_BLEND')
layout.separator()
# Output Path Section
box = layout.box()
box.label(text="Output Path:", icon='FILE_FOLDER')
row = box.row()
row.prop(submit_settings, "output_path", text="")
row = box.row()
row.prop(submit_settings, "project_size_limit_gb", text="Size limit (GB)")
def register():
"""Register panel."""
compat.safe_register_class(SHEEPIT_PT_output_panel)
def unregister():
"""Unregister panel."""
compat.safe_unregister_class(SHEEPIT_PT_output_panel)
@@ -0,0 +1,106 @@
"""
Preferences UI for SheepIt Project Submitter.
"""
import sys
import bpy
from bpy.types import AddonPreferences
from bpy.props import StringProperty
from .. import config
# Get the root module name dynamically
def _get_addon_module_name():
"""Get the root addon module name for bl_idname."""
# In Blender 5.0 extensions loaded via VSCode, the module name is the full path
# e.g., "bl_ext.vscode_development.sheepit_project_submitter"
# We need to get it from the parent package (sheepit_project_submitter)
try:
# Get parent package name from __package__ (remove .ui suffix)
if __package__:
parent_pkg = __package__.rsplit('.', 1)[0] if '.' in __package__ else __package__
# Get the actual module from sys.modules to get its __name__
parent_module = sys.modules.get(parent_pkg)
if parent_module and hasattr(parent_module, '__name__'):
module_name = parent_module.__name__
config.debug_print(f"[SheepIt Debug] Using parent module __name__ as bl_idname: {module_name}")
return module_name
else:
# Use the package name directly
config.debug_print(f"[SheepIt Debug] Using parent package name as bl_idname: {parent_pkg}")
return parent_pkg
except Exception as e:
config.debug_print(f"[SheepIt Debug] Could not get parent module name: {e}")
# Last fallback
module_name = config.ADDON_ID
config.debug_print(f"[SheepIt Debug] Using fallback bl_idname: {module_name}")
return module_name
class SHEEPIT_AddonPreferences(AddonPreferences):
"""Addon preferences for SheepIt Project Submitter."""
# bl_idname must match the add-on's module name exactly
# Get it dynamically to ensure it matches what Blender registered
bl_idname = _get_addon_module_name()
default_output_path: StringProperty(
name="Default Output Path",
description="Default directory path where packed files will be saved",
default="",
subtype='DIR_PATH',
update=lambda self, context: _sync_default_output_path(self, context),
)
def draw(self, context):
layout = self.layout
# Output path settings
box = layout.box()
box.label(text="Output Settings:", icon='FILE_FOLDER')
box.prop(self, "default_output_path")
layout.separator()
# Info box
box = layout.box()
box.label(text="About:", icon='INFO')
box.label(text="This addon packs your Blender projects for manual upload to SheepIt.")
box.label(text="You must manually upload and configure projects on the SheepIt website.")
def _sync_default_output_path(prefs, context):
"""Sync default output path to all scenes' output_path if they're empty."""
if not prefs.default_output_path:
return
# Update all scenes' output_path if they're empty
for scene in bpy.data.scenes:
if hasattr(scene, 'sheepit_submit') and scene.sheepit_submit:
if not scene.sheepit_submit.output_path:
scene.sheepit_submit.output_path = prefs.default_output_path
reg_list = [SHEEPIT_AddonPreferences]
def register():
"""Register preferences."""
# Register preferences class
for cls in reg_list:
try:
from bpy.utils import register_class
register_class(cls)
config.debug_print(f"[SheepIt Debug] Registered preferences class: {cls.__name__} with bl_idname: {cls.bl_idname}")
except Exception as e:
print(f"[SheepIt Error] Failed to register preferences class {cls.__name__}: {e}")
import traceback
traceback.print_exc()
def unregister():
"""Unregister preferences."""
# Unregister preferences class
from ..utils import compat
for cls in reg_list:
compat.safe_unregister_class(cls)
@@ -0,0 +1,21 @@
"""
Utility modules for SheepIt Project Submitter addon.
"""
from . import compat
from . import version
# Don't import auth at module level to avoid circular imports
# It will be imported lazily when needed
# Users should import it as: from ..utils.auth import <function>
# Or: from ..utils import auth (which will trigger lazy import)
__all__ = ["compat", "version"]
def __getattr__(name):
"""Lazy import for auth module to avoid circular imports."""
if name == "auth":
from . import auth
return auth
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
@@ -0,0 +1,116 @@
"""
Compatibility layer for handling differences across Blender versions.
Supports full SheepIt range: 3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 4.0, 4.1, 4.2, 4.3, 4.4, 4.5, 5.0+
Minimum supported: Blender 3.0
"""
import bpy
from bpy.utils import register_class, unregister_class
from . import version
def safe_register_class(cls):
"""
Safely register a class, handling any version-specific registration issues.
Args:
cls: The class to register
Returns:
bool: True if registration succeeded, False otherwise
"""
try:
register_class(cls)
return True
except Exception as e:
print(f"Warning: Failed to register {cls.__name__}: {e}")
return False
def safe_unregister_class(cls):
"""
Safely unregister a class, handling any version-specific unregistration issues.
Args:
cls: The class to unregister
Returns:
bool: True if unregistration succeeded, False otherwise
"""
try:
unregister_class(cls)
return True
except Exception as e:
print(f"Warning: Failed to unregister {cls.__name__}: {e}")
return False
def get_addon_prefs():
"""
Get the addon preferences instance, compatible across versions.
Returns:
AddonPreferences or None: The addon preferences instance if found
"""
from .. import config
prefs = bpy.context.preferences
addon = prefs.addons.get(config.ADDON_ID, None)
if addon and getattr(addon, "preferences", None):
return addon.preferences
for addon in prefs.addons.values():
ap = getattr(addon, "preferences", None)
if ap and hasattr(ap, "default_output_path"):
return ap
return None
def is_library_or_override(datablock):
"""
Check if a datablock is library-linked or an override.
Args:
datablock: The datablock to check
Returns:
bool: True if the datablock is library-linked or an override, False otherwise
"""
# Check if datablock is linked from a library
if hasattr(datablock, 'library') and datablock.library:
return True
# Check if datablock is an override (Blender 3.0+)
if hasattr(datablock, 'override_library') and datablock.override_library:
return True
return False
def get_file_path_map(include_libraries=False):
"""
Get file path map, handling version differences.
Args:
include_libraries (bool): Whether to include library paths
Returns:
dict: File path map
"""
try:
return bpy.data.file_path_map(include_libraries=include_libraries)
except Exception:
# Fallback for older versions
return {}
def get_user_map():
"""
Get user map, handling version differences.
Returns:
dict: User map
"""
try:
return bpy.data.user_map()
except Exception:
# Fallback for older versions
return {}
@@ -0,0 +1,139 @@
"""
Version detection and comparison utilities for multi-version Blender support.
Supports full SheepIt range: 3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 4.0, 4.1, 4.2, 4.3, 4.4, 4.5, 5.0+
Minimum supported: Blender 3.0
"""
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_3_x():
"""Check if running Blender 3.x."""
version = get_blender_version()
return version[0] == 3
def is_version_4_0():
"""Check if running Blender 4.0."""
return is_version_at_least(4, 0, 0) and is_version_less_than(4, 1, 0)
def is_version_4_1():
"""Check if running Blender 4.1."""
return is_version_at_least(4, 1, 0) and is_version_less_than(4, 2, 0)
def is_version_4_2():
"""Check if running Blender 4.2 LTS."""
version = get_blender_version()
return version[0] == 4 and version[1] == 2
def is_version_4_3():
"""Check if running Blender 4.3."""
return is_version_at_least(4, 3, 0) and is_version_less_than(4, 4, 0)
def is_version_4_4():
"""Check if running Blender 4.4."""
return is_version_at_least(4, 4, 0) and is_version_less_than(4, 5, 0)
def is_version_4_5():
"""Check if running Blender 4.5 LTS."""
return is_version_at_least(4, 5, 0) and is_version_less_than(5, 0, 0)
def is_version_5_0():
"""Check if running Blender 5.0 or later."""
return is_version_at_least(5, 0, 0)
def get_version_category():
"""
Returns the version category string for the current Blender version.
Returns:
str: Version category like '3.0', '3.x', '4.0', '4.1', '4.2', '4.3', '4.4', '4.5', or '5.0+'
"""
version = get_blender_version()
major, minor = version[0], version[1]
if major == 3:
return '3.x'
elif major == 4:
if minor == 0:
return '4.0'
elif minor == 1:
return '4.1'
elif minor == 2:
return '4.2'
elif minor == 3:
return '4.3'
elif minor == 4:
return '4.4'
elif minor >= 5:
return '4.5'
elif major >= 5:
return '5.0+'
# Fallback
return f"{major}.{minor}"