Spaces:
Running on Zero
Running on Zero
File size: 10,864 Bytes
94a0de3 c9b8b64 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 | #!/usr/bin/env python3
"""上传工程、检查资源、逐批渲染并下载结果;仅依赖 gradio_client。
新任务:python examples/client_render.py --space USER/SPACE --project scene.zip --config examples/video.json
续渲:python examples/client_render.py --space USER/SPACE --resume-job --output outputs
"""
from __future__ import annotations
import argparse
import inspect
import json
import os
from pathlib import Path
import shutil
import sys
import time
from typing import Any
from gradio_client import Client, handle_file
def emit(event: str, **values: Any) -> None:
"""使用 JSON 行输出,方便 agent 读取进度和最终结果。"""
print(json.dumps({"event": event, **values}, ensure_ascii=False), flush=True)
def save_json(path: Path, value: dict[str, Any]) -> None:
"""原子保存任务 ID,避免进程被中断后重新创建重复任务。"""
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + ".tmp")
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
try:
temporary.chmod(0o600)
except OSError:
pass
temporary.replace(path)
def make_client(space: str, cache: Path) -> Client:
# Gradio Client 新版参数为 token;兼容使用 hf_token 的旧版本。
token = os.environ.get("HF_TOKEN") or None
parameters = inspect.signature(Client).parameters
# 云端初次连接、跨区网络和大文件下载可能超过 HTTPX 默认等待时间。
kwargs: dict[str, Any] = {"verbose": False, "download_files": str(cache),
"httpx_kwargs": {"timeout": 120}}
kwargs["token" if "token" in parameters else "hf_token"] = token
return Client(space, **kwargs)
def local_file(value: Any) -> Path:
"""Client 已负责鉴权下载;只复制本机文件,不向任意 URL 转发令牌。"""
if isinstance(value, str):
path = Path(value)
elif isinstance(value, dict) and value.get("path"):
path = Path(value["path"])
else:
raise RuntimeError("API 返回了无法识别的文件结构,请更新 gradio_client。")
if not path.is_file():
raise RuntimeError(f"Client 未能下载输出文件:{path.name}")
return path
def export_results(client: Client, job_id: str, output: Path) -> dict[str, Any]:
summary, files, _image, _video = client.predict(job_id, api_name="/finalize_job")
downloaded: list[str] = []
for value in files or []:
source = local_file(value)
destination = output / source.name
if source.resolve() != destination.resolve():
shutil.copy2(source, destination)
downloaded.append(str(destination.resolve()))
save_json(output / "client-summary.json", summary)
emit("export", job_id=job_id, export_complete=summary.get("export_complete", False),
files=downloaded, error=summary.get("export_error"))
return summary
def ensure_healthy(status: dict[str, Any]) -> None:
# 部分帧成功后出错时 status 仍可能是 partial,必须同时检查 error。
if status.get("error"):
raise RuntimeError(str(status["error"]))
if status.get("status") in {"failed", "cancelled", "cancelling"}:
raise RuntimeError(f"任务已停止:{status.get('status')}。确认原因后使用 --resume-job。")
def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
result.add_argument("--space", required=True, help="HF Space ID(USER/SPACE),或 Gradio 根 URL")
result.add_argument("--project", nargs="+", type=Path, help="一个项目 ZIP,或 .blend 与资源文件")
result.add_argument("--config", type=Path, help="UTF-8 JSON 渲染配置;新任务必需")
result.add_argument("--output", type=Path, default=Path("outputs"), help="下载目录,默认 outputs")
result.add_argument("--job-file", type=Path, help="任务凭据文件,默认 OUTPUT/render-job.json")
result.add_argument("--resume-job", nargs="?", const="@job-file", metavar="JOB_ID",
help="恢复指定任务;不传 ID 时从 --job-file 读取")
result.add_argument("--inspect", "--inspect-only", dest="inspect_only", action="store_true",
help="准备并检查任务后退出,不渲染;正常渲染也会先检查")
result.add_argument("--max-batches", type=int, default=0, help="本次最多渲染几批;0 表示直到完成")
result.add_argument("--self-test", action="store_true", help="渲染前运行真实 GPU 小场景自检")
return result
def run(args: argparse.Namespace) -> int:
if args.max_batches < 0:
raise ValueError("--max-batches 不能小于零。")
args.output = args.output.expanduser().resolve()
args.output.mkdir(parents=True, exist_ok=True)
job_file = (args.job_file or args.output / "render-job.json").expanduser().resolve()
if args.resume_job is None and job_file.exists():
raise ValueError(f"任务文件已存在:{job_file}。请使用 --resume-job 续渲,或改用新的 --output。")
if args.resume_job is not None and (args.project or args.config):
raise ValueError("续渲沿用服务端原配置;请勿同时传入 --project 或 --config。")
if args.resume_job is None:
if not args.project or args.config is None:
raise ValueError("新任务必须提供 --project 和 --config。")
for path in [*args.project, args.config]:
if not path.is_file():
raise ValueError(f"找不到文件:{path}")
config_text = args.config.read_text(encoding="utf-8-sig")
if not isinstance(json.loads(config_text), dict):
raise ValueError("配置根节点必须是 JSON 对象。")
else:
config_text = "{}"
client = make_client(args.space, args.output / ".client-downloads")
job_id: str | None = None
def remember(status: dict[str, Any]) -> None:
save_json(job_file, {"space": args.space, "job_id": job_id, "updated_at": time.time(),
"status": status.get("status"), "completed_count": status.get("completed_count"),
"total_frames": status.get("total_frames")})
try:
environment = client.predict(api_name="/environment_info")
emit("environment", info=environment)
if args.self_test:
probe, _files, _image = client.predict(api_name="/gpu_self_test")
emit("self_test", report=probe)
if not probe.get("ok"):
raise RuntimeError("真实渲染自检失败,请先查看 Space 的自检日志。")
if probe.get("deployment_backend") != "cpu" and not probe.get("gpu_verified"):
raise RuntimeError("自检未确认 Blender GPU 渲染成功。")
if args.resume_job:
if args.resume_job == "@job-file":
saved = json.loads(job_file.read_text(encoding="utf-8"))
if saved.get("space") != args.space:
raise ValueError("任务文件中的 Space 与 --space 不一致;请使用原 Space 标识。")
job_id = str(saved["job_id"])
else:
job_id = args.resume_job
status = client.predict(job_id, api_name="/resume_job")
else:
uploads = [handle_file(str(path.resolve())) for path in args.project]
job_id, status = client.predict(uploads, config_text, None, api_name="/prepare_job")
remember(status)
emit("job", job_id=job_id, job_file=str(job_file), status=status.get("status"))
# 在申请 GPU 之前检查相机、场景和资源,避免浪费租约。
status = client.predict(job_id, api_name="/inspect_job")
remember(status)
save_json(args.output / "inspection.json", status)
ensure_healthy(status)
inspection = status.get("inspection") or {}
missing = inspection.get("missing_assets") or []
emit("inspection", missing_assets=missing, warnings=inspection.get("warnings", []))
if missing and status.get("config", {}).get("strict_assets", True):
raise RuntimeError("工程存在缺失或被阻止的外部资源;检查 inspection.json 并修复后创建新任务。")
if args.inspect_only:
emit("inspection_complete", job_id=job_id, next_action="使用 --resume-job 开始渲染")
return 0
batches = 0
while status.get("remaining_frames"):
before = status.get("completed_count", 0)
status = client.predict(job_id, api_name="/render_chunk")
batches += 1
remember(status)
emit("progress", job_id=job_id, status=status.get("status"),
completed=status.get("completed_count"), total=status.get("total_frames"),
error=status.get("error"))
ensure_healthy(status)
if status.get("completed_count", 0) <= before:
raise RuntimeError("本批没有完成新帧,停止重试;请检查日志和 GPU 时限。")
if args.max_batches and batches >= args.max_batches and status.get("remaining_frames"):
export_results(client, job_id, args.output)
emit("paused", job_id=job_id, message="已达到本次批数上限,可使用 --resume-job 继续。")
return 0
summary = export_results(client, job_id, args.output)
if not summary.get("export_complete"):
raise RuntimeError(summary.get("export_error") or "输出尚未完整生成。")
emit("complete", job_id=job_id, output=str(args.output))
return 0
except KeyboardInterrupt:
if job_id:
try:
remember(client.predict(job_id, api_name="/cancel_job"))
except Exception:
pass
emit("interrupted", job_id=job_id, message="已请求停止;保留任务文件后可续渲。")
return 130
except Exception:
if job_id:
# 即使某一帧失败,也尽量取回已完成帧和诊断日志。
try:
export_results(client, job_id, args.output)
except Exception:
pass
emit("resume_available", job_id=job_id, job_file=str(job_file))
raise
def main() -> int:
try:
return run(parser().parse_args())
except Exception as exc:
message = str(exc)
token = os.environ.get("HF_TOKEN")
if token:
message = message.replace(token, "[REDACTED]")
print(f"错误:{message}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
|