| 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. |
| """) |