#!/usr/bin/env python3 """H3 Console:给 gateway.py 加一层 Web UI + 上传 + 任务持久化。 不重做调度——gateway.py 已经做了选后端/分区路由/限流,这里只反代它的 /v1/videos* 接口,加三样它没有的东西:文件上传(返回可喂给 api.py 的 URL)、 SQLite 任务影子记录(gateway.py 的 JOBS 在后端进程内存里,进程重启就丢)、 静态前端。 弹性 partition 调度器(kill+restart 某个 GPU 后端切 fl2va/ref2va)只在 H3_ENABLE_SUPERVISOR=1 时启用——这一层直接操作服务器上的真实进程,本地 Mac 开发环境没有意义,见 DESIGN.md 「四点五」。 """ import os, re, sqlite3, time, uuid, json, asyncio, subprocess from pathlib import Path from typing import Optional import httpx from fastapi import FastAPI, HTTPException, UploadFile, File, Request from fastapi.responses import StreamingResponse, FileResponse from fastapi.staticfiles import StaticFiles from PIL import Image BASE = Path(__file__).resolve().parent ASSETS = BASE / "assets" ASSETS.mkdir(exist_ok=True) DB_PATH = BASE / "console.sqlite3" GATEWAY_URL = os.environ.get("H3_GATEWAY_URL", "http://192.168.110.67:8000") CONSOLE_PUBLIC_BASE = os.environ.get("H3_CONSOLE_PUBLIC_BASE") # 供后端 worker 回读上传图片用;见 README MAX_UPLOAD_BYTES = 20 * 1024 * 1024 # 20MB,够单张参考图/首尾帧,防止占满磁盘 ENABLE_SUPERVISOR = os.environ.get("H3_ENABLE_SUPERVISOR") == "1" # GPU/端口映射,对应服务器上 start-all.sh 写死的分配。仅 supervisor 用得到。 BACKEND_TOPOLOGY = [ {"gpu": 1, "port": 8010}, {"gpu": 2, "port": 8011}, {"gpu": 0, "port": 8012}, {"gpu": 3, "port": 8013}, ] H3_ROOT = Path("/data/models/minimax-h3") # 仅服务器上存在,本地开发不会用到 app = FastAPI(title="H3 Console") client = httpx.AsyncClient(timeout=30.0) def db(): con = sqlite3.connect(DB_PATH) con.row_factory = sqlite3.Row return con def init_db(): with db() as con: con.execute(""" CREATE TABLE IF NOT EXISTS jobs ( id TEXT PRIMARY KEY, gateway_id TEXT, partition TEXT NOT NULL, backend TEXT, status TEXT NOT NULL, req_json TEXT NOT NULL, created_at REAL NOT NULL, updated_at REAL NOT NULL, last_known_json TEXT ) """) init_db() # ---------------- 资产上传 ---------------- # api.py 的 GenReq 只认 URL 字段(first_frame_url / reference_image_urls),内部用 # urllib.urlopen 拉取。所以上传接口的活是「收文件 -> 落盘 -> 吐一个后端能拉到的 URL」, # 不改 api.py 一行代码。 @app.post("/v1/assets") async def upload_asset(file: UploadFile = File(...)): ext = Path(file.filename or "").suffix.lower() if ext not in (".jpg", ".jpeg", ".png", ".webp"): raise HTTPException(400, f"不支持的文件类型 {ext},只收图片(jpg/png/webp)") body = await file.read() if len(body) > MAX_UPLOAD_BYTES: raise HTTPException(413, f"文件超过 {MAX_UPLOAD_BYTES // 1024 // 1024}MB 上限") aid = uuid.uuid4().hex[:16] path = ASSETS / f"{aid}{ext}" path.write_bytes(body) try: Image.open(path).verify() # 校验确实是能解码的图片,不是随便什么二进制 except Exception: path.unlink(missing_ok=True) raise HTTPException(400, "文件不是有效图片") return {"id": aid, "url": f"{_public_base()}/assets/{aid}{ext}"} def _public_base() -> str: if CONSOLE_PUBLIC_BASE: return CONSOLE_PUBLIC_BASE.rstrip("/") # 本地开发时没配这个环境变量会直接报错提示——见 README「已知限制」: # 上传图片的 URL 必须是 api.py 所在服务器能反向拉取到的地址,本地 Mac 默认猜不出这个地址。 raise HTTPException( 500, "未设置 H3_CONSOLE_PUBLIC_BASE:上传接口需要知道自己对外可访问的地址," "这个地址会被服务器上的 api.py worker 反向拉取,见 README。") app.mount("/assets", StaticFiles(directory=str(ASSETS)), name="assets") # ---------------- 生成任务:console 自己是排队的主人,gateway.py 只是"当前有空的执行者" ---------------- # 关键点:gateway.py 对没有就绪后端的 partition 直接 503(`没有就绪的 fl2va 实例`),不排队。 # 如果 console 只是把这个 503 原样透传给用户,"有几个任务排队就用几张卡跑 fl2va"就无从谈起—— # 请求在 503 那一步就死了,根本没机会被弹性调度器看到、更别说触发 flip。 # 所以 console 自己维护身份(console 生成的 id)和排队状态:提交时先落 sqlite 记一条 status=queued, # 尝试提交给 gateway;提交不上(503/429)就留在本地队列里,交给下面的 reconciler_loop 每隔几秒重试, # 弹性调度器看到的 pending 计数正是这些「还没提交上去」的本地排队任务。 def _partition_of(req: dict) -> str: # 和 gateway.py 里 `want = "fl2va" if (...) else "ref2va"` 保持完全一致的判断, # 不要各写一套、两边判断分岔的话 console 统计出来的 pending 计数就和 gateway 实际路由对不上。 return "fl2va" if (req.get("first_frame_url") or req.get("last_frame_url")) else "ref2va" def _row_view(row) -> dict: """把一行 sqlite 记录还原成对外的任务视图,用 console 自己的 id,不暴露 gateway 内部 id。""" if row["last_known_json"]: view = json.loads(row["last_known_json"]) else: view = {} view["id"] = row["id"] view["status"] = row["status"] view["partition"] = row["partition"] view["backend"] = row["backend"] view.setdefault("req", json.loads(row["req_json"])) view["created_at"] = row["created_at"] # gateway.py/api.py 原样带过来的 `poll`/`download` 字段用的是 gateway 内部 id(不是 console # 自己的 id),照抄会指向一个 console 自己的 sqlite 里根本查不到的路径,直接 404——这两个 # 字段本来就该跟 `id` 一样,统一改成用 console 的 id 重新拼。 if "poll" in view: view["poll"] = f"/v1/videos/{row['id']}" if "download" in view: view["download"] = f"/v1/videos/{row['id']}/file" return view async def _try_submit(cid: str): """把一条本地排队的任务尝试提交给 gateway。给 create_video 首次调用和 reconciler_loop 复用。""" with db() as con: row = con.execute("SELECT * FROM jobs WHERE id=?", (cid,)).fetchone() if row is None or row["gateway_id"] is not None: return # 任务不存在,或已经提交过 req = json.loads(row["req_json"]) try: resp = await client.post(f"{GATEWAY_URL}/v1/videos", json=req) except httpx.HTTPError: return # gateway 暂时连不上,下一轮再试 now = time.time() if resp.status_code < 400: job = resp.json() gateway_id = job["id"] job["id"] = cid # 对外只暴露 console 自己的 id with db() as con: con.execute( "UPDATE jobs SET gateway_id=?, backend=?, status=?, updated_at=?, last_known_json=? WHERE id=?", (gateway_id, job.get("backend"), job.get("status", "queued"), now, json.dumps(job, ensure_ascii=False), cid), ) elif resp.status_code in (503, 429): return # 没有就绪后端 / 队列满,留在本地队列,等 flip 或别的任务跑完再试 else: # 真正的客户端错误(比如 422 参数越界、400 turbo+steps>8),重试也不会成功,直接判失败 err_view = {"id": cid, "status": "failed", "error": resp.text, "req": req, "partition": row["partition"], "created_at": row["created_at"]} with db() as con: con.execute("UPDATE jobs SET status='failed', updated_at=?, last_known_json=? WHERE id=?", (now, json.dumps(err_view, ensure_ascii=False), cid)) async def _refresh_from_gateway(cid: str, gateway_id: str): """已经提交给 gateway 的任务,去问一下最新状态,刷新本地记录。""" try: resp = await client.get(f"{GATEWAY_URL}/v1/videos/{gateway_id}") except httpx.HTTPError: return now = time.time() if resp.status_code == 404: # gateway 完全不认识这个 gateway_id——通常是所在后端进程被重启(比如手动 flip 分区 # 或崩溃),内存态清空,这条任务已经不会再有任何进展。不能像别的非 200 一样悄悄跳过, # 那样 status 会永远卡在 queued/running,界面看着像在跑,实际已经死了、永远不会再更新。 with db() as con: row = con.execute("SELECT last_known_json FROM jobs WHERE id=?", (cid,)).fetchone() if row is None: return view = json.loads(row["last_known_json"]) if row["last_known_json"] else {"id": cid} if view.get("status") in ("done", "failed"): return # 已经是终态,不要覆盖 view["status"] = "failed" view["error"] = "任务在执行途中丢失:所在后端进程被重启,内存态被清空,不会再有进展" with db() as con: con.execute( "UPDATE jobs SET status='failed', updated_at=?, last_known_json=? WHERE id=?", (now, json.dumps(view, ensure_ascii=False), cid), ) return if resp.status_code != 200: return # 其它非 200(后端一时不可达等)多半是暂时的,保留原状态,下一轮再试 job = resp.json() job["id"] = cid with db() as con: con.execute( "UPDATE jobs SET status=?, backend=?, updated_at=?, last_known_json=? WHERE id=?", (job.get("status"), job.get("backend"), now, json.dumps(job, ensure_ascii=False), cid), ) @app.post("/v1/videos", status_code=202) async def create_video(req: dict): partition = _partition_of(req) cid = uuid.uuid4().hex[:12] now = time.time() synth = {"id": cid, "status": "queued", "req": req, "partition": partition, "created_at": now} with db() as con: con.execute( "INSERT INTO jobs (id, gateway_id, partition, backend, status, req_json, created_at, updated_at, last_known_json) " "VALUES (?,NULL,?,NULL,'queued',?,?,?,?)", (cid, partition, json.dumps(req, ensure_ascii=False), now, now, json.dumps(synth, ensure_ascii=False)), ) await _try_submit(cid) # 尽力立即提交一次;提交不上就留给 reconciler_loop with db() as con: row = con.execute("SELECT * FROM jobs WHERE id=?", (cid,)).fetchone() return _row_view(row) @app.get("/v1/videos/{jid}") async def get_video(jid: str): with db() as con: row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone() if row is None: raise HTTPException(404, "无此任务") if row["gateway_id"]: await _refresh_from_gateway(jid, row["gateway_id"]) else: await _try_submit(jid) # 还在本地排队时,顺手趁轮询这一下也试着提交 with db() as con: row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone() return _row_view(row) @app.get("/v1/videos/{jid}/file") async def download_video(jid: str, request: Request): with db() as con: row = con.execute( "SELECT gateway_id, backend, status, last_known_json FROM jobs WHERE id=?", (jid,) ).fetchone() if row is None: raise HTTPException(404, "无此任务") if not row["gateway_id"] or not row["backend"]: raise HTTPException(409, "任务还在本地排队,尚未分配到可用后端") # 不经过 gateway.py 转发:它自己那层 `StreamingResponse(urlopen(...))` 不认 Range 请求, # 探测 Range 一律回 200 而不是 206,还不带 Content-Length/Accept-Ranges——`