File size: 8,311 Bytes
94a0de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d6dcc3
d443689
 
 
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
"""渲染配置:严格校验所有来自 UI、API 和 MCP 的 JSON 参数。"""

from __future__ import annotations

import json
import os
import re
from typing import Annotated, Literal

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator


MAX_CONFIG_BYTES = 64 * 1024
_DRIVE = re.compile(r"^[A-Za-z]:")


def positive_env_int(name: str, default: int) -> int:
    """读取服务端资源上限;错误配置直接报错,避免意外取消限制。"""
    value = os.environ.get(name, str(default))
    try:
        parsed = int(value)
    except (ValueError, TypeError) as exc:
        raise ValueError(f"环境变量 {name} 必须为正整数。") from exc
    if parsed <= 0:
        raise ValueError(f"环境变量 {name} 必须为正整数。")
    return parsed


def safe_relative_path(value: str) -> str:
    """保留路径原样;禁止跨平台歧义、越界路径和 Windows ADS。"""
    if not isinstance(value, str) or not value:
        raise ValueError("文件路径必须是非空相对路径。")
    if value.startswith("/") or _DRIVE.match(value) or "\\" in value:
        raise ValueError("文件路径必须使用相对路径和 / 分隔符。")
    if any(ord(char) < 32 or ord(char) == 127 for char in value):
        raise ValueError("文件路径不能包含 NUL 或控制字符。")
    parts = value.split("/")
    if any(part in {"", ".", ".."} for part in parts):
        raise ValueError("文件路径不能包含空层级、. 或 ..。")
    if any(":" in part or part.endswith((".", " ")) for part in parts):
        raise ValueError("文件路径包含不安全的跨平台文件名。")
    return value


class RenderConfig(BaseModel):
    """可直接传递给渲染任务的、版本化的严格配置。"""

    model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False)

    schema_version: Literal[1] = 1
    blend_file: str | None = None
    mode: Literal["image", "frames", "video"] = "image"
    scene: str | None = None
    camera: str | None = None
    view_layer: str | None = None
    width: Annotated[int, Field(ge=16, le=8192)] = 1280
    height: Annotated[int, Field(ge=16, le=8192)] = 720
    resolution_percentage: Annotated[int, Field(ge=1, le=100)] = 100
    samples: Annotated[int, Field(ge=1, le=4096)] = 64
    denoise: bool = True
    adaptive_threshold: Annotated[float, Field(ge=0, le=1)] = 0.02
    transparent: bool = False
    frame: Annotated[int, Field(ge=1, le=1_000_000)] = 1
    frame_start: Annotated[int, Field(ge=1, le=1_000_000)] = 1
    frame_end: Annotated[int, Field(ge=1, le=1_000_000)] = 120
    frame_step: Annotated[int, Field(ge=1, le=1_000_000)] = 1
    fps: Annotated[int, Field(ge=1, le=120)] = 24
    format: Literal["PNG", "OPEN_EXR"] = "PNG"
    color_depth: Literal[8, 16, 32] = 8
    engine: Literal["EEVEE", "CYCLES"] = "EEVEE"
    device: Literal["AUTO", "CUDA", "OPTIX", "CPU"] = "AUTO"
    gpu_size: Literal["large", "xlarge"] = "large"
    graphics_backend: Literal["opengl", "vulkan"] = "opengl"
    seed: Annotated[int, Field(ge=0, le=2_147_483_647)] = 0
    motion_blur: bool = False
    strict_assets: bool = True
    batch_size: Annotated[int, Field(ge=1, le=64)] = 1
    gpu_seconds: Annotated[int, Field(ge=10, le=600)] = 120
    cpu_timeout: Annotated[int, Field(ge=10, le=7200)] = 600
    video_crf: Annotated[int, Field(ge=0, le=40)] = 18
    video_preset: Literal["medium", "fast", "slow"] = "medium"

    @field_validator("schema_version", "color_depth", mode="before")
    @classmethod
    def require_integer_literal(cls, value: object) -> object:
        # Python 的 True == 1;Literal 仍需显式防止 bool/float 伪装成整数。
        if type(value) is not int:
            raise ValueError("此字段必须使用 JSON 整数。")
        return value

    @field_validator("blend_file")
    @classmethod
    def validate_blend_file(cls, value: str | None) -> str | None:
        if value is not None:
            safe_relative_path(value)
            if not value.casefold().endswith(".blend"):
                raise ValueError("blend_file 必须指定 .blend 文件。")
        return value

    @field_validator("scene", "camera", "view_layer")
    @classmethod
    def validate_datablock_name(cls, value: str | None) -> str | None:
        if value is not None and (
            not value.strip() or len(value) > 1024 or "\x00" in value
        ):
            raise ValueError("场景、相机和视图层名称必须为有效的非空名称。")
        return value

    @model_validator(mode="after")
    def validate_render_constraints(self) -> "RenderConfig":
        if self.engine == "EEVEE" and self.device in {"CUDA", "OPTIX"}:
            raise ValueError("CUDA / OPTIX 仅用于 Cycles;Eevee 请使用 CPU 或 AUTO。")
        if self.frame_end < self.frame_start:
            raise ValueError("frame_end 不能小于 frame_start。")
        if self.format == "PNG" and self.color_depth not in (8, 16):
            raise ValueError("PNG 的 color_depth 只能为 8 或 16。")
        if self.format == "OPEN_EXR" and self.color_depth not in (16, 32):
            raise ValueError("OPEN_EXR 的 color_depth 只能为 16 或 32。")
        if self.output_width < 1 or self.output_height < 1:
            raise ValueError("缩放后的输出尺寸必须至少为 1 × 1。")
        if self.output_width * self.output_height > positive_env_int(
            "MAX_PIXELS", 16_777_216
        ):
            raise ValueError("输出像素总数超过服务端 MAX_PIXELS 限制。")
        frame_count = 1 if self.mode == "image" else (
            (self.frame_end - self.frame_start) // self.frame_step + 1
        )
        if frame_count > positive_env_int("MAX_FRAMES", 2000):
            raise ValueError("帧数超过服务端 MAX_FRAMES 限制。")
        if self.mode == "video":
            if self.format != "PNG":
                raise ValueError("video 模式只支持 PNG 中间帧。")
            if self.transparent:
                raise ValueError("MP4 视频不支持透明背景,请设置 transparent=false。")
            if self.output_width % 2 or self.output_height % 2:
                raise ValueError("video 模式的最终宽、高必须均为偶数。")
        return self

    @property
    def output_width(self) -> int:
        return max(1, self.width * self.resolution_percentage // 100)

    @property
    def output_height(self) -> int:
        return max(1, self.height * self.resolution_percentage // 100)

    def frame_numbers(self) -> list[int]:
        """逐帧任务使用此列表;隔帧采样由编码端同步调整播放帧率以保持时间尺度。"""
        if self.mode == "image":
            return [self.frame]
        return list(range(self.frame_start, self.frame_end + 1, self.frame_step))


def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
    result: dict[str, object] = {}
    for key, value in pairs:
        if key in result:
            raise ValueError(f"JSON 包含重复字段:{key}")
        result[key] = value
    return result


def _reject_nonfinite(value: str) -> None:
    raise ValueError(f"JSON 不允许非有限数值:{value}")


def parse_config(text: str) -> RenderConfig:
    """解析 JSON;限制输入大小并拒绝重复字段、NaN 和隐式类型转换。"""
    if not isinstance(text, str):
        raise ValueError("配置必须是 JSON 文本。")
    try:
        byte_count = len(text.encode("utf-8"))
    except UnicodeEncodeError as exc:
        raise ValueError("配置文本包含无效 Unicode 字符。") from exc
    if byte_count > MAX_CONFIG_BYTES:
        raise ValueError("配置文件不能超过 64 KiB。")
    try:
        raw = json.loads(
            text, object_pairs_hook=_reject_duplicate_keys, parse_constant=_reject_nonfinite
        )
    except (json.JSONDecodeError, RecursionError) as exc:
        raise ValueError(f"无效的 JSON 配置:{exc}") from exc
    if not isinstance(raw, dict):
        raise ValueError("配置根节点必须是 JSON 对象。")
    return RenderConfig.model_validate(raw)


def json_schema() -> dict:
    """返回标准 JSON Schema,方便 agent 发现可用参数。"""
    return RenderConfig.model_json_schema()