Spaces:
Running on Zero
Running on Zero
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()
|