File size: 26,386 Bytes
94a0de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d6dcc3
 
 
 
 
 
 
94a0de3
 
 
 
 
 
 
 
 
 
 
 
 
5d6dcc3
 
 
 
 
 
 
 
 
94a0de3
 
 
 
 
 
 
 
 
 
 
 
 
 
5d6dcc3
 
 
94a0de3
 
 
 
 
 
 
 
5d6dcc3
 
 
 
 
 
 
 
8c25fe8
5d6dcc3
 
 
94a0de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d6dcc3
94a0de3
 
 
 
 
 
 
 
5d6dcc3
94a0de3
5d6dcc3
94a0de3
 
 
 
 
 
 
 
 
5d6dcc3
94a0de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d6dcc3
94a0de3
 
 
 
 
 
5d6dcc3
94a0de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
"""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 "<UDIM>" in normalized and inside(target.parent, self.root):
            tiles = list(target.parent.glob(target.name.replace("<UDIM>", "[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 == "<builtin>"):
                    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 == "<builtin>":
                    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()