import streamlit as st
import tempfile
import subprocess
import os
import requests
import base64
import sys
import shutil
import re
import logging
import platform
from streamlit_ace import st_ace
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger('blender_app')
st.set_page_config(page_title="Blender 3D Viewer", layout="wide")
st.title("🌍 Blender Script → 3D Viewer")
# Detect environment
IS_HUGGINGFACE = os.environ.get('SPACE_ID') is not None
if IS_HUGGINGFACE:
st.info("Running in Hugging Face Spaces environment")
# Find Blender executable with enhanced detection for Hugging Face
def find_blender():
# Common paths to check, expanded for Hugging Face environment
paths_to_check = [
"blender", # default PATH
"/usr/bin/blender",
"/opt/blender/blender",
"/app/bin/blender", # Common in Docker containers
"/usr/local/bin/blender",
os.path.join(os.path.expanduser("~"), "blender/blender")
]
# On HF, Blender might be installed in a custom location
if IS_HUGGINGFACE:
# Check if Blender was installed via apt.txt
hf_paths = [
"/opt/conda/bin/blender",
"/home/user/blender/blender"
]
paths_to_check = hf_paths + paths_to_check
# On Windows systems
if platform.system() == "Windows":
program_files = os.environ.get("ProgramFiles", "C:\\Program Files")
paths_to_check.append(os.path.join(program_files, "Blender Foundation", "Blender", "blender.exe"))
for path in paths_to_check:
try:
logger.info(f"Checking Blender at: {path}")
result = subprocess.run([path, "--version"],
capture_output=True,
text=True,
timeout=10)
if result.returncode == 0:
st.success(f"Found Blender: {result.stdout.strip()}")
logger.info(f"Blender found at {path}: {result.stdout.strip()}")
return path
except Exception as e:
logger.debug(f"Failed to find Blender at {path}: {str(e)}")
continue
# If not found, try using the 'which' command on Unix-like systems
if platform.system() != "Windows":
try:
result = subprocess.run(["which", "blender"],
capture_output=True,
text=True,
timeout=5)
if result.returncode == 0 and result.stdout.strip():
path = result.stdout.strip()
st.success(f"Found Blender using 'which': {path}")
logger.info(f"Blender found using 'which': {path}")
return path
except Exception as e:
logger.debug(f"Failed to use 'which' to find Blender: {str(e)}")
return None
# Enhanced script preprocessor to ensure reliable .blend file saving
def preprocess_script(script_text, tmp_dir):
logger.info("Preprocessing script...")
# Check if necessary imports exist in the script
required_imports = {
'import bpy': 'import bpy',
'import os': 'import os',
'import math': 'import math' if 'math.' in script_text else None,
'import sys': 'import sys'
}
for import_check, import_statement in required_imports.items():
if import_statement and import_check not in script_text:
script_text = import_statement + '\n' + script_text
logger.info(f"Added {import_statement} to script")
# Ensure absolute paths for file operations in Hugging Face environment
absolute_tmp_dir = os.path.abspath(tmp_dir)
logger.info(f"Using absolute temp directory: {absolute_tmp_dir}")
# More robust file saving code with error handling
if 'bpy.ops.wm.save_as_mainfile' not in script_text:
# Add enhanced file saving code at the end with detailed error handling
script_text += f'''
# Save the .blend file with error handling
try:
# Ensure the output directory exists
output_dir = os.environ.get('BLENDER_OUTPUT_DIR', '{absolute_tmp_dir}')
os.makedirs(output_dir, exist_ok=True)
# Use absolute path for the blend file
blend_file_path = os.path.abspath(os.path.join(output_dir, "scene.blend"))
# Print debug info for troubleshooting
print(f"Attempting to save blend file to: {{blend_file_path}}")
print(f"Current working directory: {{os.getcwd()}}")
# Save the file with absolute path - using both filepath and check_existing parameters
bpy.ops.wm.save_as_mainfile(filepath=blend_file_path, check_existing=False)
# Verify the file was saved
if os.path.exists(blend_file_path):
print(f"Successfully saved blend file to: {{blend_file_path}}")
else:
print(f"ERROR: Failed to save blend file - file does not exist after save operation")
except Exception as e:
print(f"ERROR saving .blend file: {{str(e)}}")
import traceback
traceback.print_exc()
'''
logger.info("Added enhanced file saving code to script")
else:
# Make sure existing save code uses absolute paths correctly and has error handling
save_pattern = r'bpy\.ops\.wm\.save_as_mainfile\s*\(\s*filepath\s*=\s*["\']([^"\']+)["\']'
if re.search(save_pattern, script_text):
script_text = re.sub(
save_pattern,
f'bpy.ops.wm.save_as_mainfile(filepath=os.path.abspath(os.path.join(os.environ.get("BLENDER_OUTPUT_DIR", "{absolute_tmp_dir}"), "scene.blend")), check_existing=False',
script_text
)
logger.info("Modified existing save code to use absolute paths")
return script_text
# Check if Blender is available at startup
blender_path = find_blender()
if not blender_path:
st.error("❌ Blender not found! The app requires Blender to be installed.")
if IS_HUGGINGFACE:
st.info("For Hugging Face Spaces, make sure to include 'blender' in your apt.txt file to install Blender.")
st.code("blender", language="text")
else:
st.info("Please install Blender and make sure it's in your PATH.")
# Continue anyway to show the interface
# Hardcoded default texture URLs (with fallbacks)
DEFAULT_TEXTURE_URLS = [
"https://eoimages.gsfc.nasa.gov/images/imagerecords/57000/57730/land_ocean_ice_2048.jpg",
"https://svs.gsfc.nasa.gov/vis/a000000/a002900/a002915/bluemarble-2048.png",
# Additional fallbacks
"https://upload.wikimedia.org/wikipedia/commons/thumb/c/cd/Blue_Marble_2007_East.jpg/600px-Blue_Marble_2007_East.jpg",
"https://www.solarsystemscope.com/textures/download/2k_earth_daymap.jpg"
]
# Sidebar: Blender script editor with example script
default_script = """import bpy
import math
# Clear the scene
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
# Create a UV Sphere for Earth
bpy.ops.mesh.primitive_uv_sphere_add(radius=1, location=(0, 0, 0))
earth = bpy.context.active_object
earth.name = 'Earth'
# Apply material to Earth
mat = bpy.data.materials.new(name="EarthMaterial")
mat.use_nodes = True
nodes = mat.node_tree.nodes
links = mat.node_tree.links
# Clear default nodes
for node in nodes:
nodes.remove(node)
# Create texture node
texture_node = nodes.new(type='ShaderNodeTexImage')
# Get texture path from environment variable if available
texture_paths = os.environ.get('TEXTURE_PATHS', '').split(',')
if texture_paths and texture_paths[0]:
texture_node.image = bpy.data.images.load(texture_paths[0])
# Add Principled BSDF node
principled = nodes.new(type='ShaderNodeBsdfPrincipled')
principled.inputs['Specular'].default_value = 0.1
principled.inputs['Roughness'].default_value = 0.8
# Add Output node
output = nodes.new(type='ShaderNodeOutputMaterial')
# Link nodes
links.new(texture_node.outputs['Color'], principled.inputs['Base Color'])
links.new(principled.outputs['BSDF'], output.inputs['Surface'])
# Assign material to Earth
if earth.data.materials:
earth.data.materials[0] = mat
else:
earth.data.materials.append(mat)
# Add a simple animation - rotation
earth.rotation_euler = (0, 0, 0)
earth.keyframe_insert(data_path="rotation_euler", frame=1)
# Rotate 360 degrees on Z axis
earth.rotation_euler = (0, 0, math.radians(360))
earth.keyframe_insert(data_path="rotation_euler", frame=250)
# Set animation interpolation to linear
for fc in earth.animation_data.action.fcurves:
for kf in fc.keyframe_points:
kf.interpolation = 'LINEAR'
# Setup camera
bpy.ops.object.camera_add(location=(0, -3, 0))
camera = bpy.context.active_object
camera.rotation_euler = (math.radians(90), 0, 0)
bpy.context.scene.camera = camera
# Setup lighting
bpy.ops.object.light_add(type='SUN', location=(5, -5, 5))
light = bpy.context.active_object
light.data.energy = 2.0
# Set render settings
bpy.context.scene.render.engine = 'CYCLES'
bpy.context.scene.cycles.device = 'CPU'
bpy.context.scene.render.film_transparent = True
# Set frame range for animation
bpy.context.scene.frame_start = 1
bpy.context.scene.frame_end = 250
"""
# Sidebar: Blender script examples
script_examples = {
"Earth with Texture": default_script,
"Solar System": """import bpy
import math
# Clear existing objects
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
# Function to create a sphere (planet or sun)
def create_celestial_body(name, radius, location, color):
# Create mesh sphere
bpy.ops.mesh.primitive_uv_sphere_add(radius=radius, location=location)
obj = bpy.context.active_object
obj.name = name
# Create material
mat = bpy.data.materials.new(name + "_Material")
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get('Principled BSDF')
bsdf.inputs['Base Color'].default_value = (*color, 1)
bsdf.inputs['Roughness'].default_value = 0.5
# Assign material
if obj.data.materials:
obj.data.materials[0] = mat
else:
obj.data.materials.append(mat)
return obj
# Create the Sun (yellow)
sun = create_celestial_body('Sun', radius=2, location=(0,0,0), color=(1.0, 1.0, 0.0))
# Create an empty at origin to parent planets for orbiting
def create_orbit_empty(name):
bpy.ops.object.empty_add(type='PLAIN_AXES', location=(0,0,0))
empty = bpy.context.active_object
empty.name = name
return empty
# Planet definitions: name, radius, distance from sun, color, orbital period in frames
planets = [
('Mercury', 0.3, 4, (0.8, 0.5, 0.2), 88),
('Venus', 0.5, 6, (1.0, 0.8, 0.0), 224),
('Earth', 0.5, 8, (0.2, 0.4, 1.0), 365),
('Mars', 0.4, 10,(1.0, 0.3, 0.2), 687)
]
for name, radius, distance, color, period in planets:
# Create orbit controller
orbit_empty = create_orbit_empty(name + '_Orbit')
# Create planet as child of orbit empty
planet = create_celestial_body(name, radius, location=(distance, 0, 0), color=color)
planet.parent = orbit_empty
# Animate the orbit: rotate empty around Z
orbit_empty.rotation_euler = (0, 0, 0)
orbit_empty.keyframe_insert(data_path='rotation_euler', frame=1, index=2)
orbit_empty.rotation_euler = (0, 0, math.radians(360))
orbit_empty.keyframe_insert(data_path='rotation_euler', frame=period, index=2)
# Set linear interpolation for smooth constant motion
action = orbit_empty.animation_data.action
fcurve = action.fcurves.find('rotation_euler', index=2)
if fcurve:
for kp in fcurve.keyframe_points:
kp.interpolation = 'LINEAR'
# Set scene frames
bpy.context.scene.frame_start = 1
bpy.context.scene.frame_end = 687 # Use Mars' period as the end frame
# Add simple starfield as world background
world = bpy.context.scene.world
world.use_nodes = True
bg = world.node_tree.nodes['Background']
bg.inputs['Color'].default_value = (0, 0, 0, 1)
# Set up a better camera view
bpy.ops.object.camera_add(location=(0, -20, 15))
camera = bpy.context.active_object
camera.rotation_euler = (math.radians(55), 0, 0)
bpy.context.scene.camera = camera
# Add a sun light
bpy.ops.object.light_add(type='SUN', location=(10, -10, 10))
sun_light = bpy.context.active_object
sun_light.data.energy = 5.0
# Set render settings
bpy.context.scene.render.engine = 'CYCLES'
bpy.context.scene.cycles.device = 'CPU'
print('Solar system animation setup complete.')""",
"Simple Cube (Test)": """import bpy
import math
# Clear the scene
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
# Create a cube
bpy.ops.mesh.primitive_cube_add(size=1, location=(0, 0, 0))
cube = bpy.context.active_object
cube.name = 'TestCube'
# Add animation
cube.rotation_euler = (0, 0, 0)
cube.keyframe_insert(data_path="rotation_euler", frame=1)
cube.rotation_euler = (0, math.radians(360), 0)
cube.keyframe_insert(data_path="rotation_euler", frame=100)
# Setup camera
bpy.ops.object.camera_add(location=(0, -3, 0))
camera = bpy.context.active_object
camera.rotation_euler = (math.radians(90), 0, 0)
bpy.context.scene.camera = camera
# Setup lighting
bpy.ops.object.light_add(type='SUN', location=(5, -5, 5))
# Set frame range
bpy.context.scene.frame_start = 1
bpy.context.scene.frame_end = 100
"""
}
# Add a more animation-focused example
script_examples["Animated Character"] = """import bpy
import math
# Clear existing objects
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
# Create simple character with basic armature
def create_simple_character():
# Create a body (cylinder)
bpy.ops.mesh.primitive_cylinder_add(radius=0.5, depth=2, location=(0, 0, 1))
body = bpy.context.active_object
body.name = "Body"
# Create a head (sphere)
bpy.ops.mesh.primitive_uv_sphere_add(radius=0.5, location=(0, 0, 2.5))
head = bpy.context.active_object
head.name = "Head"
# Create arms (cylinders)
bpy.ops.mesh.primitive_cylinder_add(radius=0.2, depth=1.5, location=(0.8, 0, 1.5))
arm_right = bpy.context.active_object
arm_right.name = "ArmRight"
arm_right.rotation_euler = (0, math.radians(90), 0)
bpy.ops.mesh.primitive_cylinder_add(radius=0.2, depth=1.5, location=(-0.8, 0, 1.5))
arm_left = bpy.context.active_object
arm_left.name = "ArmLeft"
arm_left.rotation_euler = (0, math.radians(90), 0)
# Create legs (cylinders)
bpy.ops.mesh.primitive_cylinder_add(radius=0.25, depth=1, location=(0.3, 0, 0))
leg_right = bpy.context.active_object
leg_right.name = "LegRight"
bpy.ops.mesh.primitive_cylinder_add(radius=0.25, depth=1, location=(-0.3, 0, 0))
leg_left = bpy.context.active_object
leg_left.name = "LegLeft"
return {
"body": body,
"head": head,
"arm_right": arm_right,
"arm_left": arm_left,
"leg_right": leg_right,
"leg_left": leg_left
}
# Create character
character = create_simple_character()
# Create dance animation
def create_dance_animation():
# Set frame range
bpy.context.scene.frame_start = 1
bpy.context.scene.frame_end = 60
# Arm waving
# Frame 1
bpy.context.scene.frame_set(1)
character["arm_right"].rotation_euler = (0, math.radians(90), 0)
character["arm_left"].rotation_euler = (0, math.radians(90), 0)
character["arm_right"].keyframe_insert(data_path="rotation_euler")
character["arm_left"].keyframe_insert(data_path="rotation_euler")
# Frame 15
bpy.context.scene.frame_set(15)
character["arm_right"].rotation_euler = (0, math.radians(90), math.radians(45))
character["arm_left"].rotation_euler = (0, math.radians(90), math.radians(-45))
character["arm_right"].keyframe_insert(data_path="rotation_euler")
character["arm_left"].keyframe_insert(data_path="rotation_euler")
# Frame 30
bpy.context.scene.frame_set(30)
character["arm_right"].rotation_euler = (0, math.radians(90), math.radians(-45))
character["arm_left"].rotation_euler = (0, math.radians(90), math.radians(45))
character["arm_right"].keyframe_insert(data_path="rotation_euler")
character["arm_left"].keyframe_insert(data_path="rotation_euler")
# Frame 45
bpy.context.scene.frame_set(45)
character["arm_right"].rotation_euler = (0, math.radians(90), math.radians(45))
character["arm_left"].rotation_euler = (0, math.radians(90), math.radians(-45))
character["arm_right"].keyframe_insert(data_path="rotation_euler")
character["arm_left"].keyframe_insert(data_path="rotation_euler")
# Frame 60
bpy.context.scene.frame_set(60)
character["arm_right"].rotation_euler = (0, math.radians(90), 0)
character["arm_left"].rotation_euler = (0, math.radians(90), 0)
character["arm_right"].keyframe_insert(data_path="rotation_euler")
character["arm_left"].keyframe_insert(data_path="rotation_euler")
# Body bounce
# Frame 1
bpy.context.scene.frame_set(1)
character["body"].location = (0, 0, 1)
character["body"].keyframe_insert(data_path="location")
character["head"].location = (0, 0, 2.5)
character["head"].keyframe_insert(data_path="location")
# Frame 15
bpy.context.scene.frame_set(15)
character["body"].location = (0, 0, 1.2)
character["body"].keyframe_insert(data_path="location")
character["head"].location = (0, 0, 2.7)
character["head"].keyframe_insert(data_path="location")
# Frame 30
bpy.context.scene.frame_set(30)
character["body"].location = (0, 0, 1)
character["body"].keyframe_insert(data_path="location")
character["head"].location = (0, 0, 2.5)
character["head"].keyframe_insert(data_path="location")
# Frame 45
bpy.context.scene.frame_set(45)
character["body"].location = (0, 0, 1.2)
character["body"].keyframe_insert(data_path="location")
character["head"].location = (0, 0, 2.7)
character["head"].keyframe_insert(data_path="location")
# Frame 60
bpy.context.scene.frame_set(60)
character["body"].location = (0, 0, 1)
character["body"].keyframe_insert(data_path="location")
character["head"].location = (0, 0, 2.5)
character["head"].keyframe_insert(data_path="location")
# Set interpolation
for obj in character.values():
if obj.animation_data and obj.animation_data.action:
for fc in obj.animation_data.action.fcurves:
for kp in fc.keyframe_points:
kp.interpolation = 'BEZIER'
# Create the animation
create_dance_animation()
# Set up camera and lighting
bpy.ops.object.camera_add(location=(0, -5, 2))
camera = bpy.context.active_object
camera.rotation_euler = (math.radians(80), 0, 0)
bpy.context.scene.camera = camera
# Add light
bpy.ops.object.light_add(type='SUN', location=(2, -3, 5))
sun = bpy.context.active_object
sun.data.energy = 3.0
# Set render settings
bpy.context.scene.render.engine = 'CYCLES'
bpy.context.scene.cycles.device = 'CPU'
print('Character animation setup complete')
"""
# Add example selector
selected_example = st.sidebar.selectbox(
"Load example script:",
options=list(script_examples.keys())
)
# Sidebar: Blender script editor
st.sidebar.header("Blender Python Script")
script_text = st_ace(
value=script_examples[selected_example],
placeholder="Paste your Blender Python script here...",
language="python",
theme="monokai",
key="ace",
min_lines=20,
max_lines=100,
)
# Sidebar: texture uploads
st.sidebar.header("Texture Uploads (JPG/PNG)")
uploaded_textures = st.sidebar.file_uploader(
"Upload one or more textures", type=["jpg", "jpeg", "png"], accept_multiple_files=True
)
# Sidebar: custom Python libraries
st.sidebar.header("Custom Python Libraries")
custom_packages = st.sidebar.text_area(
"List pip packages (one per line)",
height=100
)
# Add options specific to troubleshooting in Hugging Face
if IS_HUGGINGFACE:
st.sidebar.header("Hugging Face Options")
debug_mode = st.sidebar.checkbox("Enable Debug Mode", value=False)
if debug_mode:
log_level = st.sidebar.selectbox(
"Log Level",
options=["INFO", "DEBUG"],
index=0
)
if log_level == "DEBUG":
logger.setLevel(logging.DEBUG)
# Main action
if st.sidebar.button("Run & Export GLB"):
if not blender_path:
st.error("Cannot proceed: Blender is not available")
st.stop()
if not script_text or not script_text.strip():
st.error("Please provide a valid Blender Python script.")
st.stop()
with st.spinner("Processing your 3D scene..."):
# Use a more robust temporary directory approach
tmp_dir = tempfile.mkdtemp(prefix="blender_app_")
logger.info(f"Created temporary directory: {tmp_dir}")
try:
# 1) Preprocess and save user script
processed_script = preprocess_script(script_text, tmp_dir)
script_path = os.path.join(tmp_dir, "user_script.py")
with open(script_path, "w") as f:
f.write(processed_script)
logger.info(f"Saved processed script to: {script_path}")
# 2) Collect textures
texture_paths = []
if uploaded_textures:
for idx, upload in enumerate(uploaded_textures):
ext = os.path.splitext(upload.name)[1]
path = os.path.join(tmp_dir, f"texture_{idx}{ext}")
with open(path, "wb") as tf:
tf.write(upload.read())
texture_paths.append(path)
logger.info(f"Saved {len(texture_paths)} uploaded textures")
else:
# Try multiple default textures if some fail
for url in DEFAULT_TEXTURE_URLS:
try:
r = requests.get(url, timeout=15)
r.raise_for_status()
ext = os.path.splitext(url)[-1] or ".jpg"
path = os.path.join(tmp_dir, f"default{ext}")
with open(path, "wb") as tf:
tf.write(r.content)
texture_paths.append(path)
logger.info(f"Downloaded texture from {url}")
# If we got one texture successfully, that's enough
break
except Exception as e:
logger.warning(f"Could not download texture {url}: {str(e)}")
st.warning(f"Could not download texture {url}: {str(e)}")
# Display texture file information
if texture_paths:
st.subheader("Texture Files")
for tp in texture_paths:
st.code(os.path.basename(tp))
logger.info(f"Using texture: {tp}")
else:
st.warning("No textures were loaded. Your 3D model may appear without textures.")
logger.warning("No textures were loaded")
# 3) Install custom Python libraries if specified
if custom_packages and custom_packages.strip():
pkgs = [l.strip() for l in custom_packages.splitlines() if l.strip()]
if pkgs:
st.info(f"Installing: {', '.join(pkgs)}")
logger.info(f"Installing packages: {', '.join(pkgs)}")
try:
# Use Python executable from current environment for pip
pip_cmd = [sys.executable, "-m", "pip", "install", "--user"] + pkgs
pip_res = subprocess.run(
pip_cmd,
check=True,
capture_output=True,
text=True,
timeout=180 # 3 minute timeout
)
st.text_area("pip install output", pip_res.stdout + pip_res.stderr, height=100)
logger.info(f"Pip install result: {pip_res.returncode}")
except Exception as e:
st.warning(f"pip install failed: {str(e)}")
logger.error(f"pip install failed: {str(e)}")
# 4) Prepare environment with necessary variables
env = os.environ.copy()
env["TEXTURE_PATHS"] = ",".join(texture_paths)
env["BLENDER_OUTPUT_DIR"] = tmp_dir # Pass the tmp_dir to the Blender script
logger.info(f"Set environment variable BLENDER_OUTPUT_DIR={tmp_dir}")
# 5) Run Blender to build .blend file with enhanced error handling and retry
blend_path = os.path.join(tmp_dir, "scene.blend")
max_retries = 3
for attempt in range(1, max_retries + 1):
with st.status(f"Running Blender to create scene (attempt {attempt}/{max_retries})...") as status:
cmd1 = [blender_path, "--background", "--python", script_path]
logger.info(f"Running Blender command: {' '.join(cmd1)}")
try:
r1 = subprocess.run(
cmd1,
cwd=tmp_dir,
env=env,
check=True,
capture_output=True,
text=True,
timeout=300 # 5 minute timeout
)
status.update(label=f"Blender scene created successfully (attempt {attempt})", state="complete")
st.text_area("Blender output", r1.stdout + r1.stderr, height=150)
logger.info("Blender script execution completed successfully")
break
except subprocess.TimeoutExpired:
st.error(f"Blender process timed out after 5 minutes (attempt {attempt}/{max_retries}).")
logger.error(f"Blender timeout on attempt {attempt}")
if attempt == max_retries:
st.stop()
except subprocess.CalledProcessError as e:
st.error(f"Blender build failed with error code {e.returncode} (attempt {attempt}/{max_retries})")
st.text_area("Error details", e.stdout + e.stderr, height=150)
logger.error(f"Blender error on attempt {attempt}: {e.returncode}")
logger.debug(f"Blender stderr: {e.stderr}")
# If it's the last attempt, stop execution
if attempt == max_retries:
st.stop()
except Exception as e:
st.error(f"Blender build failed: {str(e)} (attempt {attempt}/{max_retries})")
logger.error(f"Unexpected error on attempt {attempt}: {str(e)}")
if attempt == max_retries:
st.stop()
# Check if blend file was created with better diagnostics
if not os.path.exists(blend_path):
st.error("Blender did not create the expected scene.blend file.")
logger.error(f"Blend file not found at {blend_path}")
# Check directory contents for troubleshooting
dir_contents = os.listdir(tmp_dir)
logger.info(f"Temporary directory contents: {dir_contents}")
st.info(f"Temporary directory contents: {', '.join(dir_contents)}")
# Try an alternative approach by writing a minimal script just to save a file
st.warning("Attempting alternative approach to create .blend file...")
logger.info("Trying alternative approach with minimal script")
minimal_script_path = os.path.join(tmp_dir, "minimal_save.py")
with open(minimal_script_path, "w") as f:
f.write(f"""
import bpy
import os
# Make sure the directory exists
os.makedirs(r'{tmp_dir}', exist_ok=True)
# Create a simple cube
bpy.ops.mesh.primitive_cube_add(size=1, location=(0, 0, 0))
# Save the file
blend_path = r'{blend_path}'
print(f"Saving to {{blend_path}}")
bpy.ops.wm.save_as_mainfile(filepath=blend_path, check_existing=False)
print(f"Save operation completed, checking if file exists: {{os.path.exists(blend_path)}}")
""")
try:
cmd_min = [blender_path, "--background", "--python", minimal_script_path]
r_min = subprocess.run(
cmd_min,
cwd=tmp_dir,
env=env,
check=True,
capture_output=True,
text=True,
timeout=120
)
st.text_area("Minimal script output", r_min.stdout + r_min.stderr, height=150)
logger.info("Minimal script execution completed")
if os.path.exists(blend_path):
st.success("Alternative approach created a .blend file successfully!")
logger.info("Alternative approach succeeded")
else:
st.error("Alternative approach also failed to create .blend file.")
logger.error("Alternative approach failed")
st.stop()
except Exception as e:
st.error(f"Alternative approach failed: {str(e)}")
logger.error(f"Alternative approach error: {str(e)}")
st.stop()
# 6) Export GLB with animation - enhanced error handling and animation support
glb_path = os.path.join(tmp_dir, "animation.glb")
with st.status("Exporting to GLB format with animations...") as status:
# Create a separate Python script for enhanced GLB export with better animation support
export_script = os.path.join(tmp_dir, "export_glb.py")
with open(export_script, "w") as f:
f.write(f"""
import bpy
import os
import sys
# Print debug info
print(f"Python version: {{sys.version}}")
print(f"Blender version: {{bpy.app.version_string}}")
print(f"Attempting to load blend file: {blend_path}")
print(f"Current working directory: {{os.getcwd()}}")
print(f"File exists check: {{os.path.exists(r'{blend_path}')}}")
try:
# Load the blend file
bpy.ops.wm.open_mainfile(filepath=r'{blend_path}')
print("Blend file loaded successfully")
# Get animation info
scene = bpy.context.scene
frame_start = scene.frame_start
frame_end = scene.frame_end
fps = scene.render.fps
print(f"Animation frames: {{frame_start}}-{{frame_end}} at {{fps}} fps")
# Export to GLB with enhanced animation options
glb_path = r'{glb_path}'
print(f"Exporting to GLB: {{glb_path}}")
# Check if glTF export is available
if hasattr(bpy.ops.export_scene, 'gltf'):
bpy.ops.export_scene.gltf(
filepath=glb_path,
export_format='GLB',
export_animations=True,
export_frame_range=True,
export_frame_step=1,
export_anim_single_armature=False, # Export all animations
export_current_frame=False,
export_apply=False # Keep animations intact
)
print(f"GLB export completed, file exists: {{os.path.exists(glb_path)}}")
else:
print("ERROR: glTF export operator not available. Check if the add-on is enabled.")
# Try to enable the addon
bpy.ops.preferences.addon_enable(module='io_scene_gltf2')
print("Attempted to enable glTF add-on")
# Try export again
if hasattr(bpy.ops.export_scene, 'gltf'):
bpy.ops.export_scene.gltf(
filepath=glb_path,
export_format='GLB',
export_animations=True,
export_frame_range=True,
export_frame_step=1,
export_anim_single_armature=False,
export_current_frame=False,
export_apply=False
)
print(f"GLB export completed after enabling add-on")
else:
print("ERROR: Could not enable glTF export add-on")
except Exception as e:
print(f"ERROR during export: {{str(e)}}")
import traceback
traceback.print_exc()
""")
cmd2 = [blender_path, "--background", "--python", export_script]
logger.info(f"Running GLB export command: {' '.join(cmd2)}")
try:
r2 = subprocess.run(
cmd2,
cwd=tmp_dir,
env=env,
check=True,
capture_output=True,
text=True,
timeout=180 # 3 minute timeout
)
status.update(label="GLB export with animations completed successfully", state="complete")
st.text_area("Export output", r2.stdout + r2.stderr, height=100)
logger.info("GLB export completed successfully")
except Exception as e:
st.error(f"GLB export failed: {str(e)}")
logger.error(f"GLB export error: {str(e)}")
st.stop()
# 7) Embed GLB inline with enhanced animation viewer
if os.path.exists(glb_path):
with open(glb_path, 'rb') as f:
data = f.read()
file_size = len(data) / (1024 * 1024) # Size in MB
st.success(f"Successfully created GLB file with animations ({file_size:.1f} MB)")
logger.info(f"Created GLB file, size: {file_size:.1f} MB")
# Check if file isn't too large for embedding
if file_size > 50:
st.warning("The GLB file is quite large. The viewer might be slow to load.")
logger.warning(f"Large GLB file: {file_size:.1f} MB")
b64 = base64.b64encode(data).decode()
# Display the 3D model viewer with animation controls
st.subheader("3D Model Viewer with Animation Controls")
html = f"""