"""Blender 内执行的渲染工作进程;请通过 Blender --python 调用。 输入只接受服务端创建的 task.json,不执行用户提供的 Python。 文件路径限制属于纵深防御,不替代操作系统对原生 .blend 解析器的隔离。 """ from __future__ import annotations import json import os import re import sys import time import traceback from pathlib import Path import bpy def atomic_json(path: Path, value: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) temp = path.with_name(path.name + ".tmp") temp.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8") os.replace(temp, path) def inside(path: Path, root: Path) -> bool: try: path.resolve().relative_to(root.resolve()) return True except (OSError, ValueError): return False def all_ids(): """遍历 Blender 的顶层数据块,不遍历文本内容,也不执行驱动器。""" seen = set() for prop in bpy.data.bl_rna.properties: if prop.type != "COLLECTION": continue for value in getattr(bpy.data, prop.identifier, ()): if isinstance(value, bpy.types.ID) and value.as_pointer() not in seen: seen.add(value.as_pointer()) yield value class AssetGuard: def __init__(self, root: Path, blend_path: Path, warnings: list): self.root = root.resolve() self.blend_path = blend_path.resolve() self.warnings = warnings self.assets = [] self._seen = set() self.files = {} self.directories = {} # 只重定位到真正上传的文件;拒绝指向目录外的符号链接。 for path in self.root.rglob("*"): if not inside(path, self.root): continue mapping = self.directories if path.is_dir() else self.files mapping.setdefault(path.name.casefold(), []).append(path.resolve()) def resolve(self, original: str, owner=None, is_dir=False): normalized = original.replace("\\", "/") base = self.blend_path.parent library = getattr(owner, "library", None) if library: try: libpath = Path(bpy.path.abspath(library.filepath)).resolve() if inside(libpath, self.root): base = libpath.parent except Exception: pass if normalized.startswith("//"): target = base / normalized[2:] elif re.match(r"^[A-Za-z]:/", normalized): target = Path("/__unavailable_windows_drive__/") / normalized[3:] else: target = Path(normalized) if not target.is_absolute(): target = base / target target = target.resolve() if inside(target, self.root) and (target.is_dir() if is_dir else target.is_file()): return target, "ok" # UDIM 路径中的 token 不是实际文件,但每张 tile 都必须在上传目录。 if "" in normalized and inside(target.parent, self.root): tiles = list(target.parent.glob(target.name.replace("", "[0-9][0-9][0-9][0-9]"))) if tiles and all(inside(p, self.root) and p.is_file() for p in tiles): return target, "ok" # 相对路径被保留时优先匹配尾部路径,适应 Windows 上传至 Linux。 parts = [p for p in normalized.split("/") if p and p not in (".", "..") and not p.endswith(":")] for count in range(min(len(parts), 8), 0, -1): candidate = (self.root.joinpath(*parts[-count:])).resolve() if inside(candidate, self.root) and (candidate.is_dir() if is_dir else candidate.is_file()): return candidate, "remapped" mapping = self.directories if is_dir else self.files matches = mapping.get(Path(normalized).name.casefold(), []) if len(matches) == 1: return matches[0], "remapped" return None, "missing" if inside(target, self.root) else "blocked" def record(self, kind, name, original, resolved, status): key = (kind, name, original) if key not in self._seen: self._seen.add(key) self.assets.append({"kind": kind, "name": name, "original": original, "resolved": str(resolved) if resolved else None, "status": status}) def clean_libraries(self): bad_libraries = set() for library in list(bpy.data.libraries): original = library.filepath # 链接库已在 open_mainfile 内加载:不能将越界库的已加载数据保留下来。 # 不对已加载的库做 basename 重定位,以免把来源判断误认为已重载成功。 raw = Path(bpy.path.abspath(original)).resolve() allowed = inside(raw, self.root) and raw.is_file() self.record("library", library.name, original, raw if allowed else None, "ok" if allowed else "blocked") if allowed: library.filepath = str(raw) else: bad_libraries.add(library) changed = True while changed: changed = False for library in bpy.data.libraries: if getattr(library, "parent", None) in bad_libraries and library not in bad_libraries: bad_libraries.add(library) changed = True if bad_libraries: unsafe = [item for item in all_ids() if getattr(item, "library", None) in bad_libraries] bpy.data.batch_remove(ids=unsafe + list(bad_libraries)) self.warnings.append("已删除上传目录外链接库及其数据块;请将库文件随项目一起上传。") def clean_datablock_files(self): # 未上传数据块直接删除,避免已经缓存于内存的外部像素或字体进入渲染。 collections = ("images", "fonts", "sounds", "movieclips", "volumes", "cache_files") remove = [] for kind in collections: for item in list(getattr(bpy.data, kind, ())): original = getattr(item, "filepath", "") if not original or (kind == "fonts" and original == ""): continue packed = bool(getattr(item, "packed_file", None)) or bool(getattr(item, "packed_files", [])) if packed: self.record(kind, item.name, original, None, "packed") continue # 程序化图像和内部渲染结果不依赖磁盘。 if kind == "images" and getattr(item, "source", "") in ("GENERATED", "VIEWER"): continue path, status = self.resolve(original, item) self.record(kind, item.name, original, path, status) if path: try: item.filepath = str(path) if kind == "images" and status == "remapped": item.reload() except (AttributeError, RuntimeError) as exc: raise RuntimeError(f"无法安全重定位资源 {item.name}: {exc}") from exc else: remove.append(item) if remove: bpy.data.batch_remove(ids=remove) def clean_rna_paths(self): # 仅检查明确参与渲染的属性;遍历整个 RNA 会进入 UI 工作区或巨大网格, # 还可能访问 Blender 中尚未初始化的指针,因此不能用通用递归扫描。 visited = set() def scan(value, owner): if value is None or not hasattr(value, "bl_rna"): return pointer = value.as_pointer() if pointer in visited: return visited.add(pointer) for prop in value.bl_rna.properties: if prop.type != "STRING" or prop.subtype not in {"FILE_PATH", "DIR_PATH"} or prop.is_readonly: continue original = getattr(value, prop.identifier) if not original or original == "": continue path, status = self.resolve(original, owner, prop.subtype == "DIR_PATH") self.record(f"{value.bl_rna.identifier}.{prop.identifier}", owner.name, original, path, status) try: setattr(value, prop.identifier, str(path) if path else "") except Exception as exc: raise RuntimeError(f"无法清除外部文件路径 {owner.name}/{prop.identifier}") from exc for scene in bpy.data.scenes: # 输出和烘焙路径不属于输入资产,由可信渲染流程另行指定。 scene.render.filepath = "" if hasattr(scene.render, "bake"): scene.render.bake.filepath = "" rigid = scene.rigidbody_world if rigid: scan(rigid.point_cache, scene) for obj in bpy.data.objects: for modifier in obj.modifiers: scan(modifier, obj) for field in ("domain_settings", "flow_settings", "effector_settings", "point_cache"): scan(getattr(modifier, field, None), obj) for bake in getattr(modifier, "bakes", ()): scan(bake, obj) canvas = getattr(modifier, "canvas_settings", None) if canvas: for surface in canvas.canvas_surfaces: scan(surface, obj) scan(surface.point_cache, obj) for system in obj.particle_systems: scan(system.point_cache, obj) scan(system.settings, obj) for texture in bpy.data.textures: scan(texture, texture) for field in ("voxel_data", "point_density"): scan(getattr(texture, field, None), texture) for cache_file in bpy.data.cache_files: # Alembic 缓存除了主文件,也可能叠加多份外部 layer 文件。 for layer in getattr(cache_file, "layers", ()): scan(layer, cache_file) trees = list(bpy.data.node_groups) for collection in (bpy.data.materials, bpy.data.worlds, bpy.data.lights): for owner in collection: tree = getattr(owner, "node_tree", None) if tree: trees.append(tree) for tree in trees: # 合成器和视频序列已经关闭,文件输出节点永远不能参与执行。 if tree.bl_idname == "CompositorNodeTree": continue scan(tree, tree) for node in tree.nodes: scan(node, tree) def clean_nodes(self): trees = list(bpy.data.node_groups) for item in all_ids(): tree = getattr(item, "node_tree", None) if tree: trees.append(tree) for tree in trees: for node in list(tree.nodes): kind = node.bl_idname # 几何节点的导入路径可能由动态字符串计算而来,不能靠静态路径检查保证安全。 if kind == "ShaderNodeScript" or (kind.startswith("GeometryNode") and ("Import" in kind or "File" in kind)): self.warnings.append(f"已禁用外部文件/脚本节点:{tree.name}/{node.name} ({kind})。") tree.nodes.remove(node) def run(self): self.clean_libraries() self.clean_datablock_files() self.clean_nodes() self.clean_rna_paths() return self.assets def harden_scenes(warnings): bpy.context.preferences.filepaths.use_scripts_auto_execute = False warnings.append("自动 Python 脚本、脚本节点和 OSL 已禁用;依赖 Python 表达式的驱动器或插件不会按原项目执行。") for scene in bpy.data.scenes: if scene.render.use_compositing: warnings.append(f"场景 {scene.name} 的合成器已禁用;输出为相机直接渲染结果。") if scene.render.use_sequencer and scene.sequence_editor: warnings.append(f"场景 {scene.name} 的视频序列编辑器已禁用。") scene.render.use_compositing = False scene.render.use_sequencer = False scene.render.use_stamp = False if hasattr(scene.render, "use_persistent_data"): scene.render.use_persistent_data = False if hasattr(scene, "cycles"): scene.cycles.shading_system = False # 禁止模拟缓存自动写入来源路径;模拟应事先烘焙并上传缓存。 for obj in scene.objects: for modifier in obj.modifiers: if modifier.type == "FLUID" and modifier.fluid_type == "DOMAIN": settings = modifier.domain_settings if settings and hasattr(settings, "cache_type"): settings.cache_type = "MODULAR" def select_scene(config): name = config.get("scene") scene = bpy.data.scenes.get(name) if name else bpy.context.scene if scene is None: raise ValueError(f"找不到场景:{name}") if bpy.context.window: bpy.context.window.scene = scene camera_name = config.get("camera") if camera_name: camera = scene.objects.get(camera_name) if camera is None or camera.type != "CAMERA": raise ValueError(f"场景 {scene.name} 中没有相机:{camera_name}") scene.camera = camera return scene def inspect_scene(assets): scenes = [] for scene in bpy.data.scenes: scenes.append({"name": scene.name, "camera": scene.camera.name if scene.camera else None, "cameras": [obj.name for obj in scene.objects if obj.type == "CAMERA"], "frame_start": scene.frame_start, "frame_end": scene.frame_end, "frame_step": scene.frame_step, "fps": scene.render.fps, "fps_base": scene.render.fps_base, "width": scene.render.resolution_x, "height": scene.render.resolution_y, "resolution_percentage": scene.render.resolution_percentage, "engine": scene.render.engine, "view_layers": [layer.name for layer in scene.view_layers]}) return {"scenes": scenes, "cameras": [obj.name for obj in bpy.data.objects if obj.type == "CAMERA"], "assets": assets, "missing_assets": [a for a in assets if a["status"] in {"missing", "blocked"}]} def configure_render(scene, config): engine = config.get("engine", "EEVEE") if engine == "CYCLES": scene.render.engine = "CYCLES" else: engines = scene.render.bl_rna.properties["engine"].enum_items.keys() scene.render.engine = "BLENDER_EEVEE_NEXT" if "BLENDER_EEVEE_NEXT" in engines else "BLENDER_EEVEE" for key, default in (("resolution_x", 960), ("resolution_y", 540)): source = "width" if key == "resolution_x" else "height" setattr(scene.render, key, int(config.get(source, default))) scene.render.resolution_percentage = int(config.get("resolution_percentage", 100)) scene.render.film_transparent = bool(config.get("transparent", False)) scene.render.fps = int(config.get("fps", 24)) scene.render.fps_base = 1.0 scene.render.use_file_extension = True scene.render.use_overwrite = True scene.render.use_placeholder = False scene.render.image_settings.file_format = config.get("format", "PNG") scene.render.image_settings.color_mode = "RGBA" scene.render.image_settings.color_depth = str(config.get("color_depth", 8)) if engine == "CYCLES": scene.cycles.samples = int(config.get("samples", 64)) scene.cycles.use_denoising = bool(config.get("denoise", True)) scene.cycles.use_adaptive_sampling = True scene.cycles.adaptive_threshold = float(config.get("adaptive_threshold", 0.01)) scene.cycles.seed = int(config.get("seed", 0)) scene.cycles.shading_system = False else: scene.eevee.taa_render_samples = int(config.get("samples", 64)) if hasattr(scene.render, "use_motion_blur"): scene.render.use_motion_blur = bool(config.get("motion_blur", False)) elif hasattr(scene.cycles, "use_motion_blur"): scene.cycles.use_motion_blur = bool(config.get("motion_blur", False)) layer_name = config.get("view_layer") if layer_name: if scene.view_layers.get(layer_name) is None: raise ValueError(f"找不到视图层:{layer_name}") for layer in scene.view_layers: layer.use = layer.name == layer_name if not scene.camera: raise ValueError("场景没有活动相机,请在 JSON 中指定 camera 或先在 Blender 中添加相机。") def device_candidates(requested, engine="CYCLES"): if engine == "EEVEE": return ("CPU" if requested == "CPU" else "EEVEE_GPU",) if requested == "AUTO": return ("OPTIX", "CUDA") if requested not in {"OPTIX", "CUDA", "CPU"}: raise ValueError(f"不支持的设备类型:{requested}") return (requested,) def use_device(scene, backend): if scene.render.engine != "CYCLES": import gpu if hasattr(gpu, "init"): gpu.init() renderer = gpu.platform.renderer_get() vendor = gpu.platform.vendor_get() software = any(tag in renderer.lower() for tag in ("llvmpipe", "softpipe", "swrast", "software", "lavapipe")) if backend != "CPU" and software: raise RuntimeError(f"GPU + Eevee 检测到软件渲染器:{renderer}。服务器未提供可用的硬件图形接口;请检查 GPU 分区和 OpenGL/Vulkan 驱动。不会回退至 CPU。") if backend == "CPU" and not software: raise RuntimeError("CPU Eevee 未确认 Mesa 软件渲染器;拒绝误用硬件 GPU。") return [{"name": renderer, "vendor": vendor, "type": "CPU" if software else "GPU", "use": True}] addon = bpy.context.preferences.addons.get("cycles") if addon is None: raise RuntimeError("此 Blender 安装不包含 Cycles。") prefs = addon.preferences if backend == "CPU": scene.cycles.device = "CPU" return [{"name": "CPU", "type": "CPU", "use": True}] prefs.compute_device_type = backend if hasattr(prefs, "refresh_devices"): prefs.refresh_devices() else: prefs.get_devices() devices = [] for device in prefs.devices: # 绝不启用混合 CPU 渲染;ZeroGPU 验证失败时必须明确报错。 device.use = device.type == backend if device.use: devices.append({"name": device.name, "type": device.type, "id": device.id, "use": True}) if not devices: raise RuntimeError(f"Cycles 未找到可用的 {backend} GPU;拒绝静默回退至 CPU。") scene.cycles.device = "GPU" return devices def choose_device(scene, requested): failures = [] for backend in device_candidates(requested): try: return backend, use_device(scene, backend) except Exception as exc: failures.append(f"{backend}: {exc}") raise RuntimeError("GPU 设备初始化失败。" + " | ".join(failures)) def make_probe_scene(): bpy.ops.wm.read_factory_settings(use_empty=True) scene = bpy.context.scene bpy.ops.mesh.primitive_cube_add() obj = bpy.context.object obj.rotation_euler = (0.2, 0.3, 0.0) bpy.ops.object.camera_add(location=(3, -4, 3)) camera = bpy.context.object camera.rotation_euler = (-camera.location).to_track_quat("-Z", "Y").to_euler() scene.camera = camera bpy.ops.object.light_add(type="AREA", location=(2, -2, 4)) bpy.context.object.data.energy = 500 bpy.context.object.data.size = 3 return scene def render_one(scene, output_dir, frame, config): extension = ".exr" if config.get("format", "PNG") == "OPEN_EXR" else ".png" final = output_dir / f"frame_{frame:06d}{extension}" temporary = output_dir / f".frame_{frame:06d}.partial{extension}" scene.frame_set(frame) scene.render.filepath = str(temporary) started = time.monotonic() bpy.ops.render.render(write_still=True, scene=scene.name) if not temporary.is_file() or temporary.stat().st_size == 0: raise RuntimeError(f"Blender 没有生成第 {frame} 帧输出。") os.replace(temporary, final) record = {"frame": frame, "path": str(final), "seconds": round(time.monotonic() - started, 3), "bytes": final.stat().st_size} # 每帧刷盘:GPU 租约中途结束时,父进程仍能恢复已经完成的帧。 with (output_dir / "completed.jsonl").open("a", encoding="utf-8") as handle: handle.write(json.dumps(record, ensure_ascii=False) + "\n") handle.flush() os.fsync(handle.fileno()) print("RENDER_FRAME_COMPLETED " + json.dumps(record), flush=True) return record def main(): if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1:]) != 1: raise ValueError("用法:blender --background --factory-startup --disable-autoexec --python render_worker.py -- task.json") task_path = Path(sys.argv[sys.argv.index("--") + 1]).resolve(strict=True) task = json.loads(task_path.read_text(encoding="utf-8")) result_path = Path(task["result_path"]).resolve() output_dir = Path(task["output_dir"]).resolve() output_dir.mkdir(parents=True, exist_ok=True) config = task.get("config", {}) result = {"ok": False, "action": task["action"], "blender_version": bpy.app.version_string, "engine": config.get("engine", "EEVEE"), "warnings": [], "devices": [], "backend": None, "frames": [], "timings": {}} start = time.monotonic() try: action = task["action"] if action == "probe": scene = make_probe_scene() probe_config = {**config, "width": 32, "height": 32, "resolution_percentage": 100, "samples": 1, "denoise": False, "format": "PNG", "color_depth": "8"} harden_scenes(result["warnings"]) configure_render(scene, probe_config) failures = [] for backend in device_candidates(config.get("device", "CPU"), config.get("engine", "EEVEE")): try: result["devices"] = use_device(scene, backend) result["backend"] = backend result["frames"] = [render_one(scene, output_dir, 1, probe_config)] result["probe_render_succeeded"] = True break except Exception as exc: failures.append(f"{backend}: {exc}") else: raise RuntimeError("实际渲染探针失败。" + " | ".join(failures)) result["warnings"].extend(failures) elif action in {"inspect", "render"}: root = Path(task["project_root"]).resolve(strict=True) blend_path = Path(task["blend_path"]).resolve(strict=True) if not inside(blend_path, root) or blend_path.suffix.lower() != ".blend": raise ValueError(".blend 文件必须位于上传项目目录内。") bpy.context.preferences.filepaths.use_scripts_auto_execute = False bpy.ops.wm.open_mainfile(filepath=str(blend_path), load_ui=False, use_scripts=False) harden_scenes(result["warnings"]) assets = AssetGuard(root, blend_path, result["warnings"]).run() result.update(inspect_scene(assets)) result["timings"]["load_and_inspect_seconds"] = round(time.monotonic() - start, 3) if action == "render": if config.get("strict_assets", True) and result["missing_assets"]: missing = ", ".join(a["name"] for a in result["missing_assets"][:8]) raise ValueError(f"存在缺失或越界资源:{missing}。请上传完整资源,或将 strict_assets 设为 false 后接受缺失效果。") scene = select_scene(config) configure_render(scene, config) frames = task.get("frames") or [int(config.get("frame", config.get("frame_start", scene.frame_current)))] for frame in frames: if not isinstance(frame, int) or isinstance(frame, bool) or abs(frame) > 1_048_574: raise ValueError("帧编号必须是 Blender 支持范围内的整数。") # AUTO 必须通过实际首帧验证;OPTIX 初始化成功也可能在加载内核时失败。 failures = [] for backend in device_candidates(config.get("device", "CPU"), config.get("engine", "EEVEE")): try: result["devices"] = use_device(scene, backend) result["backend"] = backend result["frames"].append(render_one(scene, output_dir, frames[0], config)) break except Exception as exc: failures.append(f"{backend}: {exc}") else: raise RuntimeError("所有指定后端的首帧实际渲染均失败;没有回退至 CPU。" + " | ".join(failures)) result["warnings"].extend(failures) atomic_json(result_path, result) for frame in frames[1:]: result["frames"].append(render_one(scene, output_dir, frame, config)) result["timings"]["elapsed_seconds"] = round(time.monotonic() - start, 3) atomic_json(result_path, result) else: raise ValueError(f"未知任务:{action}") result["ok"] = True except Exception as exc: result["error"] = str(exc) result["error_type"] = type(exc).__name__ traceback.print_exc() finally: result["timings"]["elapsed_seconds"] = round(time.monotonic() - start, 3) atomic_json(result_path, result) if not result["ok"]: raise RuntimeError(result.get("error", "渲染失败")) if __name__ == "__main__": main()