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,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)