From 8b1a0d221e51e061ea7b59e199e4314a920d5c0f Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 9 Sep 2026 11:52:43 +0800 Subject: [PATCH] h3-console: 参考图加服务端缩略图 + 删除任务改为先取消底层再回收站 --- h3-console/API.md | 10 +++++----- h3-console/server.py | 56 ++++++++++++++++++++++++++++++++++++++++++++++++-------- h3-console/static/index.html | 17 +++++++++++++---- 3 files changed, 66 insertions(+), 17 deletions(-) diff --git a/h3-console/API.md b/h3-console/API.md index 872e3a1..d1624d2 100644 --- a/h3-console/API.md +++ b/h3-console/API.md @@ -71,10 +71,10 @@ token 是否强制取决于部署时是否设了 `H3_CONSOLE_AUTH_TOKEN` 环境å | GET | `/v1/videos/{id}/comfy_history` | 排查用:原始 ComfyUI 执行记录 | | POST | `/v1/videos/{id}/cancel` | 取消排队中的任务 / 停止运行中的任务 | | POST | `/v1/videos/{id}/prioritize` | 插队到最前面(仅排队中的任务) | -| DELETE | `/v1/videos/{id}` | 软删除(移入回收站) | +| DELETE | `/v1/videos/{id}` | 取消底层任务 + 移入回收站 | | POST | `/v1/videos/{id}/restore` | 从回收站恢复 | | POST | `/v1/videos/batch-cancel` | **批量**取消/停止 | -| POST | `/v1/videos/batch-delete` | **批量**软删除 | +| POST | `/v1/videos/batch-delete` | **批量**取消底层任务 + 移入回收站 | | POST | `/v1/assets` | 上传参考图/参考视频/参考音频,换取可用的 URL | | GET | `/v1/backends` | 健康检查 + 队列深度 + GPU 显存/利用率 | | GET | `/v1/models` | 支持的分辨率档位(含实测帧数上限) | @@ -284,9 +284,9 @@ query 参数(全部可选): 响应里的 `queue_position` 是插队后的新位置。 -### `DELETE /v1/videos/{id}` — 软删除 +### `DELETE /v1/videos/{id}` — 删除 -移入回收站(`deleted_at` 设成当前时间)。**不影响底层任务本身**——如果这条任务还在排队/运行中,它会照常跑完,只是从 console 的主列表里消失了。真要连底层计算一起停掉,配合 `cancel` 一起调(顺序不影响结果)。 +先按 `cancel` 的语义真正取消底层任务(释放算力;任务已经是终态则跳过这步),再移入回收站(`deleted_at` 设成当前时间)。不删磁盘文件。 ```json {"id": "xxx", "deleted": true} @@ -338,7 +338,7 @@ query 参数(全部可选): } ``` -`batch-cancel` 对批量里混进的 `running` 任务一样会触发硬重启(见上面单条 cancel 的说明)——如果批量目标里可能包含正在运行的任务,先用筛选条件把 `status` 限定成 `queued`,避免误停正在跑的任务。 +`batch-cancel` 和 `batch-delete`(内部也会先 cancel)对批量里混进的 `running` 任务一样会触发硬重启(见上面单条 cancel 的说明)——如果批量目标里可能包含正在运行的任务,先用筛选条件把 `status` 限定成 `queued`,避免误停正在跑的任务。 --- diff --git a/h3-console/server.py b/h3-console/server.py index 8a175f7..392664e 100644 --- a/h3-console/server.py +++ b/h3-console/server.py @@ -622,8 +622,16 @@ async def list_videos(limit: int = 20, offset: int = 0, trash: bool = False, q: return {"jobs": [_row_view(r) for r in rows], "total": total} -def _delete_job(jid: str) -> dict: - """真正干活的软删除逻辑,单条/批量共用,理由同 _cancel_job。""" +async def _delete_job(jid: str) -> dict: + """真正干活的删除逻辑,单条/批量共用。先调 _cancel_job 让底层任务真正停掉、释放算力, + 任务已经是终态(done/failed/cancelled)时 _cancel_job 会 409——这种情况没算力可释放, + 忽略这个 409 继续走软删除;其它取消失败(比如后端连不上)直接把错误透传出去,不能在 + 算力还没释放的时候就把任务从列表里挪走,制造"删了但其实还在跑"的假象。""" + try: + await _cancel_job(jid) + except HTTPException as e: + if e.status_code != 409: + raise with db() as con: cur = con.execute("UPDATE jobs SET deleted_at=? WHERE id=? AND deleted_at IS NULL", (time.time(), jid)) if cur.rowcount == 0: @@ -633,8 +641,8 @@ def _delete_job(jid: str) -> dict: @api.delete("/v1/videos/{jid}") async def delete_video(jid: str): - """软删除:只挪出主列表,不碰 gateway/api.py 上的任务本身(可能还在跑),不删磁盘文件。""" - return _delete_job(jid) + """删除:先真正取消底层任务(释放算力),再移入回收站。不删磁盘文件。""" + return await _delete_job(jid) @api.post("/v1/videos/{jid}/restore") @@ -702,14 +710,13 @@ async def batch_cancel_videos(body: dict): @api.post("/v1/videos/batch-delete") async def batch_delete_videos(body: dict): - """批量软删除(移入回收站),语义同单条 DELETE /v1/videos/{jid}——只是把任务从列表里挪走, - 不取消底层计算;真要连底层任务一起停掉,配合上面的 batch-cancel 一起调(谁先谁后不影响 - 结果,两个操作互不冲突)。""" + """批量删除,语义同单条 DELETE /v1/videos/{jid}:逐条先取消底层任务(释放算力), + 再移入回收站。""" ids = _resolve_batch_targets(body) succeeded, failed = [], [] for jid in ids: try: - succeeded.append(_delete_job(jid)["id"]) + succeeded.append((await _delete_job(jid))["id"]) except HTTPException as e: failed.append({"id": jid, "error": e.detail}) return {"requested": len(ids), "succeeded": succeeded, "failed": failed} @@ -919,6 +926,39 @@ async def newtask_page(): return render_index() +# 2026-09-09:列表/详情页缩略图之前直接用参考图原图(1-2MB/张),加缓存头只解决了 +# "重复"下载,首次打开还是要传那么大。这里给图片单独开一条缩略图路径——注意跟 /assets/* +# 是两个独立的东西:job req 里存的 first_frame_url/reference_image_urls 一个字节都不动, +# 那是喂给生成模型的真实输入,缩略图只是前端展示用,绝不能让前端不小心把这条 URL 存回 +# req 里、拿小图去生成视频。thumb_url() 是纯字符串规则从 /assets/{filename} 反推 +# /thumbs/{filename},不改 upload_asset 返回值,老任务的原始 URL 一样能配出缩略图。 +# 首次访问时惰性生成、落盘缓存,之后跟原图一样标 immutable——参考图传上来就不会再变。 +THUMB_DIR = ASSETS / "_thumbs" +THUMB_DIR.mkdir(exist_ok=True) +THUMB_MAX_SIDE = 320 # 列表缩略图 88x118 CSS px、详情页预览 160x160 CSS px,320 留够 2x retina + + +@router.get("/thumbs/{filename}") +async def get_thumb(filename: str): + src = (ASSETS / filename).resolve() + if ASSETS.resolve() not in src.parents or not src.is_file(): + raise HTTPException(404, "无此文件") + thumb_path = THUMB_DIR / (src.stem + ".jpg") + if not thumb_path.exists(): + try: + img = Image.open(src) + img.thumbnail((THUMB_MAX_SIDE, THUMB_MAX_SIDE)) + tmp_path = thumb_path.with_suffix(".tmp") + img.convert("RGB").save(tmp_path, "JPEG", quality=82) + tmp_path.replace(thumb_path) # 原子改名,防止并发首次请求读到写一半的文件 + except Exception: + # 不是图片能解码的文件(参考视频/音频也存在 assets/ 里,理论上不会被前端拿来 + # 调这个接口,这里只是防御性兜底)——原样把源文件当"缩略图"返回。 + return FileResponse(src, headers={"Cache-Control": "public, max-age=31536000, immutable"}) + return FileResponse(thumb_path, media_type="image/jpeg", + headers={"Cache-Control": "public, max-age=31536000, immutable"}) + + # BASE_PATH 留空时这个前缀是空字符串,router 上的路径原样挂到 app 根上,行为跟以前 # 直接用 @app.xxx 一模一样;非空时(比如 "/console")整体套一层前缀,一次性生效, # 不用每个路由自己拼。 diff --git a/h3-console/static/index.html b/h3-console/static/index.html index e25fe51..87a2752 100644 --- a/h3-console/static/index.html +++ b/h3-console/static/index.html @@ -296,6 +296,15 @@ function h3Token() { } return t; } +// 2026-09-09:只重写本机 /assets/ 的上传文件到服务端缩略图路径(/thumbs/{filename},见 +// server.py),外部图床 URL("外部 URL 不经过 /v1/assets 转存"那条路径)原样返回—— +// 后端没有那些文件,生不出缩略图。绝不改 req 里存的原始 URL 本身,只用于展示。 +function thumbUrl(u) { + if (!u) return u; + const i = u.indexOf('/assets/'); + if (i === -1) return u; + return u.slice(0, i) + '/thumbs/' + u.slice(i + '/assets/'.length); +} function h3FileUrl(path) { //