2026-02-16
This commit is contained in:
@@ -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 don’t 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}"
|
||||
Reference in New Issue
Block a user