|
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 |
|
|
|
|
|
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") |
|
|
|
|
|
IS_HUGGINGFACE = os.environ.get('SPACE_ID') is not None |
|
if IS_HUGGINGFACE: |
|
st.info("Running in Hugging Face Spaces environment") |
|
|
|
|
|
def find_blender(): |
|
|
|
paths_to_check = [ |
|
"blender", |
|
"/usr/bin/blender", |
|
"/opt/blender/blender", |
|
"/app/bin/blender", |
|
"/usr/local/bin/blender", |
|
os.path.join(os.path.expanduser("~"), "blender/blender") |
|
] |
|
|
|
|
|
if IS_HUGGINGFACE: |
|
|
|
hf_paths = [ |
|
"/opt/conda/bin/blender", |
|
"/home/user/blender/blender" |
|
] |
|
paths_to_check = hf_paths + paths_to_check |
|
|
|
|
|
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 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 |
|
|
|
|
|
def preprocess_script(script_text, tmp_dir): |
|
logger.info("Preprocessing 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") |
|
|
|
|
|
absolute_tmp_dir = os.path.abspath(tmp_dir) |
|
logger.info(f"Using absolute temp directory: {absolute_tmp_dir}") |
|
|
|
|
|
if 'bpy.ops.wm.save_as_mainfile' not in script_text: |
|
|
|
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: |
|
|
|
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 |
|
|
|
|
|
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.") |
|
|
|
|
|
|
|
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", |
|
|
|
"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" |
|
] |
|
|
|
|
|
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 |
|
""" |
|
|
|
|
|
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 |
|
""" |
|
} |
|
|
|
|
|
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') |
|
""" |
|
|
|
|
|
selected_example = st.sidebar.selectbox( |
|
"Load example script:", |
|
options=list(script_examples.keys()) |
|
) |
|
|
|
|
|
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, |
|
) |
|
|
|
|
|
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 |
|
) |
|
|
|
|
|
st.sidebar.header("Custom Python Libraries") |
|
custom_packages = st.sidebar.text_area( |
|
"List pip packages (one per line)", |
|
height=100 |
|
) |
|
|
|
|
|
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) |
|
|
|
|
|
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..."): |
|
|
|
tmp_dir = tempfile.mkdtemp(prefix="blender_app_") |
|
logger.info(f"Created temporary directory: {tmp_dir}") |
|
|
|
try: |
|
|
|
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}") |
|
|
|
|
|
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: |
|
|
|
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}") |
|
|
|
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)}") |
|
|
|
|
|
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") |
|
|
|
|
|
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: |
|
|
|
pip_cmd = [sys.executable, "-m", "pip", "install", "--user"] + pkgs |
|
pip_res = subprocess.run( |
|
pip_cmd, |
|
check=True, |
|
capture_output=True, |
|
text=True, |
|
timeout=180 |
|
) |
|
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)}") |
|
|
|
|
|
env = os.environ.copy() |
|
env["TEXTURE_PATHS"] = ",".join(texture_paths) |
|
env["BLENDER_OUTPUT_DIR"] = tmp_dir |
|
logger.info(f"Set environment variable BLENDER_OUTPUT_DIR={tmp_dir}") |
|
|
|
|
|
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 |
|
) |
|
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 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() |
|
|
|
|
|
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}") |
|
|
|
|
|
dir_contents = os.listdir(tmp_dir) |
|
logger.info(f"Temporary directory contents: {dir_contents}") |
|
st.info(f"Temporary directory contents: {', '.join(dir_contents)}") |
|
|
|
|
|
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() |
|
|
|
|
|
glb_path = os.path.join(tmp_dir, "animation.glb") |
|
|
|
with st.status("Exporting to GLB format with animations...") as status: |
|
|
|
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 |
|
) |
|
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() |
|
|
|
|
|
if os.path.exists(glb_path): |
|
with open(glb_path, 'rb') as f: |
|
data = f.read() |
|
|
|
file_size = len(data) / (1024 * 1024) |
|
st.success(f"Successfully created GLB file with animations ({file_size:.1f} MB)") |
|
logger.info(f"Created GLB file, size: {file_size:.1f} MB") |
|
|
|
|
|
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() |
|
|
|
|
|
st.subheader("3D Model Viewer with Animation Controls") |
|
html = f""" |
|
<script type="module" src="https://unpkg.com/@google/model-viewer@latest/dist/model-viewer.min.js"></script> |
|
<style> |
|
.animation-controls {{ |
|
display: flex; |
|
justify-content: center; |
|
margin-top: 10px; |
|
gap: 8px; |
|
}} |
|
.animation-btn {{ |
|
background-color: #4CAF50; |
|
border: none; |
|
color: white; |
|
padding: 8px 16px; |
|
text-align: center; |
|
text-decoration: none; |
|
display: inline-block; |
|
font-size: 14px; |
|
margin: 4px 2px; |
|
cursor: pointer; |
|
border-radius: 4px; |
|
}} |
|
#animation-name {{ |
|
font-weight: bold; |
|
margin-right: 10px; |
|
}} |
|
</style> |
|
|
|
<model-viewer id="model-viewer" |
|
src="data:model/gltf-binary;base64,{b64}" |
|
alt="3D Model with Animation" |
|
camera-controls |
|
auto-rotate |
|
ar |
|
shadow-intensity="1" |
|
animation-name="" |
|
autoplay |
|
style="width:100%; height:500px; background-color: #f0f0f0;"> |
|
<div class="progress-bar hide" slot="progress-bar"> |
|
<div class="update-bar"></div> |
|
</div> |
|
</model-viewer> |
|
|
|
<div class="animation-controls"> |
|
<span id="animation-name">No animation playing</span> |
|
<button id="play-btn" class="animation-btn">Play</button> |
|
<button id="pause-btn" class="animation-btn">Pause</button> |
|
<button id="stop-btn" class="animation-btn">Stop</button> |
|
</div> |
|
|
|
<script> |
|
const modelViewer = document.getElementById('model-viewer'); |
|
const playBtn = document.getElementById('play-btn'); |
|
const pauseBtn = document.getElementById('pause-btn'); |
|
const stopBtn = document.getElementById('stop-btn'); |
|
const animNameDisplay = document.getElementById('animation-name'); |
|
|
|
// Wait for the model to load |
|
modelViewer.addEventListener('load', () => {{ |
|
// Get available animations |
|
const animationNames = modelViewer.availableAnimations; |
|
console.log('Available animations:', animationNames); |
|
|
|
if (animationNames && animationNames.length > 0) {{ |
|
// Set first animation as default |
|
modelViewer.animationName = animationNames[0]; |
|
animNameDisplay.textContent = 'Playing: ' + animationNames[0]; |
|
modelViewer.play(); |
|
}} else {{ |
|
animNameDisplay.textContent = 'No animations available'; |
|
}} |
|
}}); |
|
|
|
// Animation controls |
|
playBtn.addEventListener('click', () => {{ |
|
modelViewer.play(); |
|
if (modelViewer.animationName) {{ |
|
animNameDisplay.textContent = 'Playing: ' + modelViewer.animationName; |
|
}} |
|
}}); |
|
|
|
pauseBtn.addEventListener('click', () => {{ |
|
modelViewer.pause(); |
|
if (modelViewer.animationName) {{ |
|
animNameDisplay.textContent = 'Paused: ' + modelViewer.animationName; |
|
}} |
|
}}); |
|
|
|
stopBtn.addEventListener('click', () => {{ |
|
modelViewer.pause(); |
|
modelViewer.currentTime = 0; |
|
if (modelViewer.animationName) {{ |
|
animNameDisplay.textContent = 'Stopped: ' + modelViewer.animationName; |
|
}} |
|
}}); |
|
</script> |
|
""" |
|
st.components.v1.html(html, height=600) |
|
|
|
|
|
st.download_button( |
|
"β¬οΈ Download GLB File with Animation", |
|
data, |
|
file_name="animation.glb", |
|
mime="model/gltf-binary" |
|
) |
|
else: |
|
st.error("GLB file was not generated successfully.") |
|
logger.error(f"GLB file not found at {glb_path}") |
|
|
|
if os.path.exists(blend_path): |
|
st.info("The Blender file was created, but the GLB export failed.") |
|
logger.info("Blend file exists but GLB export failed") |
|
|
|
finally: |
|
|
|
if IS_HUGGINGFACE and debug_mode: |
|
st.info(f"Debug mode: Temporary files kept at {tmp_dir}") |
|
logger.info(f"Keeping temp files for debugging: {tmp_dir}") |
|
else: |
|
try: |
|
shutil.rmtree(tmp_dir, ignore_errors=True) |
|
logger.info(f"Cleaned up temporary directory: {tmp_dir}") |
|
except Exception as e: |
|
st.warning(f"Failed to clean up temporary files: {str(e)}") |
|
logger.error(f"Cleanup error: {str(e)}") |
|
|
|
|
|
st.divider() |
|
with st.expander("About this app"): |
|
st.markdown(""" |
|
**Blender 3D Viewer App** lets you: |
|
|
|
- Write Blender Python scripts directly in your browser |
|
- Upload custom textures for your 3D models |
|
- Generate and visualize 3D models with animations |
|
- Download the results as GLB files for use in other applications |
|
|
|
The app automatically adds necessary imports and file saving code to your scripts, |
|
so you can focus on creating 3D content without worrying about environment details. |
|
|
|
Running on Hugging Face Spaces: This app requires Blender to be installed. Make sure |
|
your Space includes 'blender' in the apt.txt file. |
|
""") |
|
|
|
with st.expander("Script Tips"): |
|
st.markdown(""" |
|
### Tips for writing Blender scripts |
|
|
|
1. **Accessing textures**: Use the environment variable `TEXTURE_PATHS` to get the paths to uploaded textures |
|
|
|
```python |
|
# Example code to load the first uploaded texture |
|
texture_paths = os.environ.get('TEXTURE_PATHS', '').split(',') |
|
if texture_paths and texture_paths[0]: |
|
image = bpy.data.images.load(texture_paths[0]) |
|
``` |
|
|
|
2. **You don't need to add file saving code** - the app automatically adds it |
|
|
|
3. **Animations**: Make sure to set keyframes if you want your model to animate: |
|
|
|
```python |
|
# Example animation (rotate object 360 degrees over 250 frames) |
|
obj.rotation_euler = (0, 0, 0) |
|
obj.keyframe_insert(data_path="rotation_euler", frame=1) |
|
obj.rotation_euler = (0, 0, math.radians(360)) |
|
obj.keyframe_insert(data_path="rotation_euler", frame=250) |
|
``` |
|
|
|
4. **Troubleshooting in Hugging Face**: If you're having issues with the app in Hugging Face Spaces: |
|
|
|
- Enable Debug Mode in the sidebar |
|
- Check the error messages in the app output |
|
- Make sure your apt.txt file includes 'blender' |
|
- Try the "Simple Cube (Test)" example first to verify Blender is working |
|
""") |
|
|
|
with st.expander("Animation Tips"): |
|
st.markdown(""" |
|
### Tips for creating animations that show properly in GLB |
|
|
|
1. **Set frame range properly**: Always define your animation frame range |
|
```python |
|
bpy.context.scene.frame_start = 1 |
|
bpy.context.scene.frame_end = 60 # End frame of your animation |
|
``` |
|
|
|
2. **Use keyframes properly**: Add keyframes at specific frames |
|
```python |
|
# Set current frame |
|
bpy.context.scene.frame_set(1) |
|
|
|
# Set object properties |
|
obj.location = (0, 0, 0) |
|
|
|
# Insert keyframe for that property |
|
obj.keyframe_insert(data_path="location") |
|
``` |
|
|
|
3. **Animation export settings**: The app automatically handles GLB export with proper animation settings |
|
|
|
4. **Animation playback**: Use the animation controls below the 3D viewer to play/pause/stop your animation |
|
|
|
5. **Multiple animations**: If your GLB has multiple animations, they will be detected and can be selected in the viewer |
|
|
|
6. **Looping animations**: By default, animations will loop in the viewer |
|
""") |
|
|
|
|
|
with st.expander("Hugging Face Spaces Setup"): |
|
st.markdown(""" |
|
### Setting up this app on Hugging Face Spaces |
|
|
|
To ensure this app works correctly on Hugging Face: |
|
|
|
1. **Create an apt.txt file** in your repository with: |
|
``` |
|
blender |
|
``` |
|
|
|
2. **Create a requirements.txt file** with: |
|
``` |
|
streamlit |
|
streamlit-ace |
|
requests |
|
``` |
|
|
|
3. **Set the Space SDK** to "Streamlit" in the Space settings |
|
|
|
4. **Set resource allocation** to at least 2 CPU + 4GB RAM for better performance |
|
|
|
Troubleshooting: If you encounter "Blender not found" errors, check your apt.txt file and make sure Blender is being installed correctly. |
|
""") |