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())