Refactor runner and installation scripts for improved functionality

- Removed the `--disable-hiprt` flag from the runner command, simplifying the rendering options for users.
- Updated the `jiggablend-runner` script and README to reflect the removal of the HIPRT control flag, enhancing clarity in usage instructions.
- Enhanced the installation script to provide clearer examples for running the jiggablend manager and runner, improving user experience during setup.
- Implemented a more robust GPU backend detection mechanism, allowing for better compatibility with various hardware configurations.
This commit is contained in:
2026-03-14 21:08:06 -05:00
parent 28cb50492c
commit 16d6a95058
30 changed files with 1041 additions and 782 deletions

123
pkg/blendfile/version.go Normal file
View File

@@ -0,0 +1,123 @@
package blendfile
import (
"compress/gzip"
"fmt"
"io"
"os"
"os/exec"
)
// ParseVersionFromReader parses the Blender version from a reader.
// Returns major and minor version numbers.
//
// Blend file header layout (12 bytes):
//
// "BLENDER" (7) + pointer-size (1: '-'=64, '_'=32) + endian (1: 'v'=LE, 'V'=BE)
// + version (3 digits, e.g. "402" = 4.02)
//
// Supports uncompressed, gzip-compressed, and zstd-compressed blend files.
func ParseVersionFromReader(r io.ReadSeeker) (major, minor int, err error) {
header := make([]byte, 12)
n, err := r.Read(header)
if err != nil || n < 12 {
return 0, 0, fmt.Errorf("failed to read blend file header: %w", err)
}
if string(header[:7]) != "BLENDER" {
r.Seek(0, 0)
return parseCompressedVersion(r)
}
return parseVersionDigits(header[9:12])
}
// ParseVersionFromFile opens a blend file and parses the Blender version.
func ParseVersionFromFile(blendPath string) (major, minor int, err error) {
file, err := os.Open(blendPath)
if err != nil {
return 0, 0, fmt.Errorf("failed to open blend file: %w", err)
}
defer file.Close()
return ParseVersionFromReader(file)
}
// VersionString returns a formatted version string like "4.2".
func VersionString(major, minor int) string {
return fmt.Sprintf("%d.%d", major, minor)
}
func parseVersionDigits(versionBytes []byte) (major, minor int, err error) {
if len(versionBytes) != 3 {
return 0, 0, fmt.Errorf("expected 3 version digits, got %d", len(versionBytes))
}
fmt.Sscanf(string(versionBytes[0]), "%d", &major)
fmt.Sscanf(string(versionBytes[1:3]), "%d", &minor)
return major, minor, nil
}
func parseCompressedVersion(r io.ReadSeeker) (major, minor int, err error) {
magic := make([]byte, 4)
if _, err := r.Read(magic); err != nil {
return 0, 0, err
}
r.Seek(0, 0)
// gzip: 0x1f 0x8b
if magic[0] == 0x1f && magic[1] == 0x8b {
gzReader, err := gzip.NewReader(r)
if err != nil {
return 0, 0, fmt.Errorf("failed to create gzip reader: %w", err)
}
defer gzReader.Close()
header := make([]byte, 12)
n, err := gzReader.Read(header)
if err != nil || n < 12 {
return 0, 0, fmt.Errorf("failed to read compressed blend header: %w", err)
}
if string(header[:7]) != "BLENDER" {
return 0, 0, fmt.Errorf("invalid blend file format")
}
return parseVersionDigits(header[9:12])
}
// zstd: 0x28 0xB5 0x2F 0xFD
if magic[0] == 0x28 && magic[1] == 0xb5 && magic[2] == 0x2f && magic[3] == 0xfd {
return parseZstdVersion(r)
}
return 0, 0, fmt.Errorf("unknown blend file format")
}
func parseZstdVersion(r io.ReadSeeker) (major, minor int, err error) {
r.Seek(0, 0)
cmd := exec.Command("zstd", "-d", "-c")
cmd.Stdin = r
stdout, err := cmd.StdoutPipe()
if err != nil {
return 0, 0, fmt.Errorf("failed to create zstd stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return 0, 0, fmt.Errorf("failed to start zstd decompression: %w", err)
}
header := make([]byte, 12)
n, readErr := io.ReadFull(stdout, header)
cmd.Process.Kill()
cmd.Wait()
if readErr != nil || n < 12 {
return 0, 0, fmt.Errorf("failed to read zstd compressed blend header: %v", readErr)
}
if string(header[:7]) != "BLENDER" {
return 0, 0, fmt.Errorf("invalid blend file format in zstd archive")
}
return parseVersionDigits(header[9:12])
}

View File

@@ -0,0 +1,96 @@
package blendfile
import (
"bytes"
"compress/gzip"
"testing"
)
func makeBlendHeader(major, minor int) []byte {
header := make([]byte, 12)
copy(header[:7], "BLENDER")
header[7] = '-'
header[8] = 'v'
header[9] = byte('0' + major)
header[10] = byte('0' + minor/10)
header[11] = byte('0' + minor%10)
return header
}
func TestParseVersionFromReader_Uncompressed(t *testing.T) {
tests := []struct {
name string
major int
minor int
wantMajor int
wantMinor int
}{
{"Blender 4.02", 4, 2, 4, 2},
{"Blender 3.06", 3, 6, 3, 6},
{"Blender 2.79", 2, 79, 2, 79},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
header := makeBlendHeader(tt.major, tt.minor)
r := bytes.NewReader(header)
major, minor, err := ParseVersionFromReader(r)
if err != nil {
t.Fatalf("ParseVersionFromReader: %v", err)
}
if major != tt.wantMajor || minor != tt.wantMinor {
t.Errorf("got %d.%d, want %d.%d", major, minor, tt.wantMajor, tt.wantMinor)
}
})
}
}
func TestParseVersionFromReader_GzipCompressed(t *testing.T) {
header := makeBlendHeader(4, 2)
// Pad to ensure gzip has enough data for a full read
data := make([]byte, 128)
copy(data, header)
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
gz.Write(data)
gz.Close()
r := bytes.NewReader(buf.Bytes())
major, minor, err := ParseVersionFromReader(r)
if err != nil {
t.Fatalf("ParseVersionFromReader (gzip): %v", err)
}
if major != 4 || minor != 2 {
t.Errorf("got %d.%d, want 4.2", major, minor)
}
}
func TestParseVersionFromReader_InvalidMagic(t *testing.T) {
data := []byte("NOT_BLEND_DATA_HERE")
r := bytes.NewReader(data)
_, _, err := ParseVersionFromReader(r)
if err == nil {
t.Fatal("expected error for invalid magic, got nil")
}
}
func TestParseVersionFromReader_TooShort(t *testing.T) {
data := []byte("SHORT")
r := bytes.NewReader(data)
_, _, err := ParseVersionFromReader(r)
if err == nil {
t.Fatal("expected error for short data, got nil")
}
}
func TestVersionString(t *testing.T) {
got := VersionString(4, 2)
want := "4.2"
if got != want {
t.Errorf("VersionString(4, 2) = %q, want %q", got, want)
}
}

View File

@@ -361,6 +361,9 @@ func RunCommandWithStreaming(
}
}
}
if err := scanner.Err(); err != nil && !isBenignPipeReadError(err) {
logSender(taskID, types.LogLevelWarn, fmt.Sprintf("stdout read error: %v", err), stepName)
}
}()
go func() {
@@ -375,6 +378,9 @@ func RunCommandWithStreaming(
}
}
}
if err := scanner.Err(); err != nil && !isBenignPipeReadError(err) {
logSender(taskID, types.LogLevelWarn, fmt.Sprintf("stderr read error: %v", err), stepName)
}
}()
err = cmd.Wait()

View File

@@ -11,6 +11,3 @@ var UnhideObjects string
//go:embed scripts/render_blender.py.template
var RenderBlenderTemplate string
//go:embed scripts/detect_gpu_backends.py
var DetectGPUBackends string

View File

@@ -1,39 +0,0 @@
# Minimal script to detect HIP (AMD) and NVIDIA (CUDA/OptiX) backends for Cycles.
# Run with: blender -b --python detect_gpu_backends.py
# Prints HAS_HIP and/or HAS_NVIDIA to stdout, one per line.
import sys
def main():
try:
prefs = bpy.context.preferences
if not hasattr(prefs, 'addons') or 'cycles' not in prefs.addons:
return
cprefs = prefs.addons['cycles'].preferences
has_hip = False
has_nvidia = False
for device_type in ('HIP', 'CUDA', 'OPTIX'):
try:
cprefs.compute_device_type = device_type
cprefs.refresh_devices()
devs = []
if hasattr(cprefs, 'get_devices'):
devs = cprefs.get_devices()
elif hasattr(cprefs, 'devices') and cprefs.devices:
devs = list(cprefs.devices) if hasattr(cprefs.devices, '__iter__') else [cprefs.devices]
if devs:
if device_type == 'HIP':
has_hip = True
if device_type in ('CUDA', 'OPTIX'):
has_nvidia = True
except Exception:
pass
if has_hip:
print('HAS_HIP', flush=True)
if has_nvidia:
print('HAS_NVIDIA', flush=True)
except Exception as e:
print('ERROR', str(e), file=sys.stderr, flush=True)
sys.exit(1)
import bpy
main()

View File

@@ -175,13 +175,9 @@ if render_settings_override:
if current_engine == 'CYCLES':
# Check if CPU rendering is forced
force_cpu = False
disable_hiprt = False
if render_settings_override and render_settings_override.get('force_cpu'):
force_cpu = render_settings_override.get('force_cpu', False)
print("Force CPU rendering is enabled - skipping GPU detection")
if render_settings_override and render_settings_override.get('disable_hiprt'):
disable_hiprt = render_settings_override.get('disable_hiprt', False)
print("Disable HIPRT flag is enabled")
# Ensure Cycles addon is enabled
try:
@@ -213,9 +209,10 @@ if current_engine == 'CYCLES':
traceback.print_exc()
sys.exit(1)
# Check all devices and choose the best GPU type
# Device type preference order (most performant first)
device_type_preference = ['OPTIX', 'CUDA', 'HIP', 'ONEAPI', 'METAL']
# Check all devices and choose the best GPU type.
# Explicit fallback policy: NVIDIA -> Intel -> AMD -> CPU.
# (OPTIX/CUDA are NVIDIA, ONEAPI is Intel, HIP/OPENCL are AMD)
device_type_preference = ['OPTIX', 'CUDA', 'ONEAPI', 'HIP', 'OPENCL']
gpu_available = False
best_device_type = None
best_gpu_devices = []
@@ -325,16 +322,7 @@ if current_engine == 'CYCLES':
try:
if best_device_type == 'HIP':
# HIPRT (HIP Ray Tracing) for AMD GPUs
if disable_hiprt:
if hasattr(cycles_prefs, 'use_hiprt'):
cycles_prefs.use_hiprt = False
print(f" Disabled HIPRT (HIP Ray Tracing) via runner compatibility flag")
elif hasattr(scene.cycles, 'use_hiprt'):
scene.cycles.use_hiprt = False
print(f" Disabled HIPRT (HIP Ray Tracing) via runner compatibility flag")
else:
print(f" HIPRT toggle not available on this Blender version")
elif hasattr(cycles_prefs, 'use_hiprt'):
if hasattr(cycles_prefs, 'use_hiprt'):
cycles_prefs.use_hiprt = True
print(f" Enabled HIPRT (HIP Ray Tracing) for faster rendering")
elif hasattr(scene.cycles, 'use_hiprt'):
@@ -356,16 +344,6 @@ if current_engine == 'CYCLES':
scene.cycles.use_optix_denoising = True
print(f" Enabled OptiX denoising (if OptiX available)")
print(f" CUDA ray tracing active")
elif best_device_type == 'METAL':
# MetalRT for Apple Silicon (if available)
if hasattr(scene.cycles, 'use_metalrt'):
scene.cycles.use_metalrt = True
print(f" Enabled MetalRT (Metal Ray Tracing) for faster rendering")
elif hasattr(cycles_prefs, 'use_metalrt'):
cycles_prefs.use_metalrt = True
print(f" Enabled MetalRT (Metal Ray Tracing) for faster rendering")
else:
print(f" MetalRT not available")
elif best_device_type == 'ONEAPI':
# Intel oneAPI - Embree might be available
if hasattr(scene.cycles, 'use_embree'):