Spaces:
Running on Zero
Running on Zero
| """ | |
| Render script executed inside a fresh ``blender -b scene.blend --python render_script.py`` | |
| subprocess. Not imported by the app. | |
| Usage (args after ``--``): | |
| out_path engine samples res_x res_y [cam_mode azimuth elevation zoom focal] | |
| [mode fps frame_start frame_end] | |
| engine: CYCLES_GPU | CYCLES_CPU | EEVEE | WORKBENCH | |
| cam_mode: "scene" (use/repair the scene camera) | "orbit" (place a camera on an orbit | |
| around the scene bounds at the given azimuth/elevation, zoom factor and focal length) | |
| mode: "still" (default) | "anim" (H.264 MP4 via Blender's FFmpeg encoder; fps / | |
| frame_start / frame_end of 0 mean "use the scene's own settings") | |
| """ | |
| import sys | |
| import bpy # type: ignore # noqa: F401 - only importable inside Blender | |
| MAX_ANIM_FRAMES = 600 | |
| argv = sys.argv[sys.argv.index("--") + 1:] | |
| out_path, engine = argv[0], argv[1] | |
| samples, res_x, res_y = int(argv[2]), int(argv[3]), int(argv[4]) | |
| cam_mode = argv[5] if len(argv) > 5 else "scene" | |
| if len(argv) > 9: | |
| cam_azimuth, cam_elevation = float(argv[6]), float(argv[7]) | |
| cam_zoom, cam_focal = float(argv[8]), float(argv[9]) | |
| else: | |
| cam_azimuth, cam_elevation, cam_zoom, cam_focal = 315.0, 30.0, 1.0, 50.0 | |
| if len(argv) > 13: | |
| render_mode = argv[10] | |
| arg_fps, arg_f_start, arg_f_end = int(argv[11]), int(argv[12]), int(argv[13]) | |
| else: | |
| render_mode, arg_fps, arg_f_start, arg_f_end = "still", 0, 0, 0 | |
| scene = bpy.context.scene | |
| scene.render.resolution_x = res_x | |
| scene.render.resolution_y = res_y | |
| scene.render.resolution_percentage = 100 | |
| if render_mode == "anim": | |
| if arg_fps > 0: | |
| scene.render.fps = arg_fps | |
| if arg_f_start > 0: | |
| scene.frame_start = arg_f_start | |
| if arg_f_end > 0: | |
| scene.frame_end = arg_f_end | |
| if scene.frame_end <= scene.frame_start: | |
| scene.frame_end = scene.frame_start + max(scene.render.fps * 2, 24) | |
| if scene.frame_end - scene.frame_start + 1 > MAX_ANIM_FRAMES: | |
| scene.frame_end = scene.frame_start + MAX_ANIM_FRAMES - 1 | |
| # Blender 5.x selects video output via media_type; older builds via file_format. | |
| if hasattr(scene.render.image_settings, "media_type"): | |
| scene.render.image_settings.media_type = "VIDEO" | |
| else: | |
| scene.render.image_settings.file_format = "FFMPEG" | |
| scene.render.ffmpeg.format = "MPEG4" | |
| scene.render.ffmpeg.codec = "H264" | |
| scene.render.ffmpeg.constant_rate_factor = "MEDIUM" | |
| scene.render.ffmpeg.audio_codec = "NONE" | |
| # Blender appends its own frame-range suffix; render to a prefix and rename after. | |
| _out_base = out_path[:-4] if out_path.lower().endswith(".mp4") else out_path | |
| scene.render.filepath = _out_base | |
| else: | |
| scene.render.image_settings.file_format = "PNG" | |
| scene.render.filepath = out_path | |
| def _scene_bounds(): | |
| import mathutils | |
| points = [] | |
| for obj in scene.objects: | |
| if obj.type in {"MESH", "CURVE", "FONT", "SURFACE", "META"} and obj.visible_get(): | |
| for corner in obj.bound_box: | |
| points.append(obj.matrix_world @ mathutils.Vector(corner)) | |
| if not points: | |
| return mathutils.Vector((0, 0, 0)), 2.0 | |
| lo = mathutils.Vector((min(p[i] for p in points) for i in range(3))) | |
| hi = mathutils.Vector((max(p[i] for p in points) for i in range(3))) | |
| center = (lo + hi) / 2 | |
| size = max((hi - lo).length, 0.5) | |
| return center, size | |
| def _setup_orbit_camera(center, size): | |
| """Place a dedicated camera on an orbit around the scene bounds, aimed at center.""" | |
| import math | |
| import mathutils | |
| az = math.radians(cam_azimuth) | |
| el = math.radians(max(-89.0, min(89.0, cam_elevation))) | |
| radius = (size * 1.6) / max(cam_zoom, 0.2) + 0.4 | |
| location = mathutils.Vector(( | |
| center.x + radius * math.cos(el) * math.cos(az), | |
| center.y + radius * math.cos(el) * math.sin(az), | |
| center.z + radius * math.sin(el), | |
| )) | |
| cam = bpy.data.objects.get("OrbitCamera") | |
| if cam is None or cam.type != "CAMERA": | |
| cam_data = bpy.data.cameras.new("OrbitCamera") | |
| cam = bpy.data.objects.new("OrbitCamera", cam_data) | |
| scene.collection.objects.link(cam) | |
| cam.location = location | |
| cam.data.lens = max(10.0, min(250.0, cam_focal)) | |
| cam.data.clip_end = max(100.0, size * 10) | |
| cam.rotation_euler = (center - location).to_track_quat("-Z", "Y").to_euler() | |
| scene.camera = cam | |
| def _ensure_camera_and_light(): | |
| """Guarantee a renderable scene: camera framing the content, a light, a world.""" | |
| import math | |
| import mathutils | |
| center, size = _scene_bounds() | |
| if cam_mode == "orbit": | |
| _setup_orbit_camera(center, size) | |
| elif scene.camera is None: | |
| cam_data = bpy.data.cameras.new("AutoCamera") | |
| cam = bpy.data.objects.new("AutoCamera", cam_data) | |
| scene.collection.objects.link(cam) | |
| direction = mathutils.Vector((1.0, -1.0, 0.65)).normalized() | |
| cam.location = center + direction * (size * 1.8 + 0.5) | |
| look = center - cam.location | |
| cam.rotation_euler = look.to_track_quat("-Z", "Y").to_euler() | |
| cam_data.clip_end = max(100.0, size * 10) | |
| scene.camera = cam | |
| else: | |
| # An agent-placed camera sometimes points nowhere near the content, which | |
| # yields a black frame. If the scene center is behind the camera or far off | |
| # the view axis, re-aim (keep the location) and back off if too close. | |
| cam = scene.camera | |
| view_dir = (cam.matrix_world.to_quaternion() @ mathutils.Vector((0, 0, -1))).normalized() | |
| to_center = center - cam.matrix_world.translation | |
| if to_center.length < size * 0.5: | |
| cam.location = center - view_dir * (size * 1.8 + 0.5) | |
| to_center = center - cam.matrix_world.translation | |
| if to_center.length > 1e-6 and view_dir.dot(to_center.normalized()) < math.cos( | |
| math.radians(45) | |
| ): | |
| cam.rotation_euler = to_center.to_track_quat("-Z", "Y").to_euler() | |
| cam.data.clip_end = max(cam.data.clip_end, size * 10) | |
| scene.render.film_transparent = False | |
| if not any(o.type == "LIGHT" for o in scene.objects): | |
| sun_data = bpy.data.lights.new("AutoSun", type="SUN") | |
| sun_data.energy = 3.0 | |
| sun = bpy.data.objects.new("AutoSun", sun_data) | |
| scene.collection.objects.link(sun) | |
| sun.location = center + mathutils.Vector((0, 0, size * 3 + 2)) | |
| sun.rotation_euler = (math.radians(35), math.radians(10), math.radians(25)) | |
| if scene.world is None: | |
| scene.world = bpy.data.worlds.new("AutoWorld") | |
| scene.world.use_nodes = True | |
| bg = scene.world.node_tree.nodes.get("Background") | |
| if bg is not None and tuple(bg.inputs[0].default_value)[:3] == (0.0, 0.0, 0.0): | |
| bg.inputs[0].default_value = (0.05, 0.05, 0.06, 1.0) | |
| def _configure_cycles_gpu() -> str: | |
| prefs = bpy.context.preferences.addons["cycles"].preferences | |
| for device_type in ("OPTIX", "CUDA"): | |
| try: | |
| prefs.compute_device_type = device_type | |
| except TypeError: | |
| continue | |
| try: | |
| prefs.get_devices() | |
| except Exception as ex: # noqa: BLE001 | |
| print(f"get_devices({device_type}) failed: {ex}") | |
| gpus = [d for d in prefs.devices if d.type == device_type] | |
| if gpus: | |
| for d in prefs.devices: | |
| d.use = d.type in {device_type, "CPU"} | |
| scene.cycles.device = "GPU" | |
| print(f"Cycles using {device_type}: {[d.name for d in gpus]}") | |
| return device_type | |
| scene.cycles.device = "CPU" | |
| print("No GPU compute device found, falling back to CPU") | |
| return "CPU" | |
| _ensure_camera_and_light() | |
| if engine.startswith("CYCLES"): | |
| scene.render.engine = "CYCLES" | |
| scene.cycles.samples = samples | |
| scene.cycles.use_denoising = True | |
| if engine == "CYCLES_GPU": | |
| used = _configure_cycles_gpu() | |
| else: | |
| scene.cycles.device = "CPU" | |
| used = "CPU" | |
| elif engine == "WORKBENCH": | |
| scene.render.engine = "BLENDER_WORKBENCH" | |
| used = "WORKBENCH" | |
| else: | |
| scene.render.engine = "BLENDER_EEVEE_NEXT" | |
| scene.eevee.taa_render_samples = samples | |
| used = "EEVEE" | |
| if render_mode == "anim": | |
| bpy.ops.render.render(animation=True) | |
| # Locate whatever Blender actually wrote (it may add a frame-range suffix) | |
| # and move it to the exact requested path. | |
| import glob | |
| import os | |
| if not os.path.exists(out_path): | |
| candidates = sorted( | |
| glob.glob(scene.render.filepath + "*"), key=os.path.getmtime, reverse=True | |
| ) | |
| candidates = [c for c in candidates if c.lower().endswith((".mp4", ".mov", ".avi"))] | |
| if candidates: | |
| os.replace(candidates[0], out_path) | |
| else: | |
| bpy.ops.render.render(write_still=True) | |
| print(f"RENDER_DONE device={used} mode={render_mode} path={out_path}") | |