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