blender-render-studio / examples /client_render.py
cpuai's picture
Record verified ZeroGPU CUDA rendering and improve client timeout
c9b8b64 verified
Raw
History Blame Contribute Delete
10.9 kB
#!/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())