Commit 8b1a0d221e51e061ea7b59e199e4314a920d5c0f

Authored by yibo
1 parent 3fd25a2b

h3-console: 参考图加服务端缩略图 + 删除任务改为先取消底层再回收站

参考图列表/详情缩略图之前直接用原图(1-2MB/张),新增 GET /thumbs/{filename}
惰性生成 320px 长边的 JPEG 缩略图并落盘缓存,实测压缩到原图的0.6%;只重写本机
/assets/ 的展示用URL,job req 里喂给生成模型的原始URL不受影响。

DELETE /v1/videos/{id} 和 batch-delete 顺带改成先真正取消底层任务释放算力,
再移入回收站,避免"删了但其实还在跑"。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJEL5jqdZR1QPToULRaTAz
h3-console/API.md
@@ -71,10 +71,10 @@ token 是å¦å¼ºåˆ¶å–决于部署时是å¦è®¾äº† `H3_CONSOLE_AUTH_TOKEN` 环境å @@ -71,10 +71,10 @@ token 是å¦å¼ºåˆ¶å–决于部署时是å¦è®¾äº† `H3_CONSOLE_AUTH_TOKEN` 环境å
71 | GET | `/v1/videos/{id}/comfy_history` | 排查用:原始 ComfyUI 执行记录 | 71 | GET | `/v1/videos/{id}/comfy_history` | 排查用:原始 ComfyUI 执行记录 |
72 | POST | `/v1/videos/{id}/cancel` | å–æ¶ˆæŽ’队中的任务 / åœæ­¢è¿è¡Œä¸­çš„任务 | 72 | POST | `/v1/videos/{id}/cancel` | å–æ¶ˆæŽ’队中的任务 / åœæ­¢è¿è¡Œä¸­çš„任务 |
73 | POST | `/v1/videos/{id}/prioritize` | æ’队到最å‰é¢ï¼ˆä»…排队中的任务) | 73 | POST | `/v1/videos/{id}/prioritize` | æ’队到最å‰é¢ï¼ˆä»…排队中的任务) |
74 -| DELETE | `/v1/videos/{id}` | 软删除(移入回收站) | 74 +| DELETE | `/v1/videos/{id}` | å–æ¶ˆåº•层任务 + 移入回收站 |
75 | POST | `/v1/videos/{id}/restore` | 从回收站æ¢å¤ | 75 | POST | `/v1/videos/{id}/restore` | 从回收站æ¢å¤ |
76 | POST | `/v1/videos/batch-cancel` | **批é‡**å–æ¶ˆ/åœæ­¢ | 76 | POST | `/v1/videos/batch-cancel` | **批é‡**å–æ¶ˆ/åœæ­¢ |
77 -| POST | `/v1/videos/batch-delete` | **批é‡**软删除 | 77 +| POST | `/v1/videos/batch-delete` | **批é‡**å–æ¶ˆåº•层任务 + 移入回收站 |
78 | POST | `/v1/assets` | 上传å‚考图/å‚考视频/å‚考音频,æ¢å–å¯ç”¨çš„ URL | 78 | POST | `/v1/assets` | 上传å‚考图/å‚考视频/å‚考音频,æ¢å–å¯ç”¨çš„ URL |
79 | GET | `/v1/backends` | å¥åº·æ£€æŸ¥ + 队列深度 + GPU 显存/利用率 | 79 | GET | `/v1/backends` | å¥åº·æ£€æŸ¥ + 队列深度 + GPU 显存/利用率 |
80 | GET | `/v1/models` | 支æŒçš„分辨率档ä½ï¼ˆå«å®žæµ‹å¸§æ•°ä¸Šé™ï¼‰ | 80 | GET | `/v1/models` | 支æŒçš„分辨率档ä½ï¼ˆå«å®žæµ‹å¸§æ•°ä¸Šé™ï¼‰ |
@@ -284,9 +284,9 @@ query 傿•°ï¼ˆå…¨éƒ¨å¯é€‰ï¼‰ï¼š @@ -284,9 +284,9 @@ query 傿•°ï¼ˆå…¨éƒ¨å¯é€‰ï¼‰ï¼š
284 284
285 å“应里的 `queue_position` 是æ’队åŽçš„æ–°ä½ç½®ã€‚ 285 å“应里的 `queue_position` 是æ’队åŽçš„æ–°ä½ç½®ã€‚
286 286
287 -### `DELETE /v1/videos/{id}` — 软删除 287 +### `DELETE /v1/videos/{id}` — 删除
288 288
289 -移入回收站(`deleted_at` 设æˆå½“剿—¶é—´ï¼‰ã€‚**ä¸å½±å“底层任务本身**——如果这æ¡ä»»åŠ¡è¿˜åœ¨æŽ’é˜Ÿ/è¿è¡Œä¸­ï¼Œå®ƒä¼šç…§å¸¸è·‘å®Œï¼Œåªæ˜¯ä»Ž console 的主列表里消失了。真è¦è¿žåº•å±‚è®¡ç®—ä¸€èµ·åœæŽ‰ï¼Œé…åˆ `cancel` 一起调(顺åºä¸å½±å“结果)。 289 +先按 `cancel` çš„è¯­ä¹‰çœŸæ­£å–æ¶ˆåº•å±‚ä»»åŠ¡ï¼ˆé‡Šæ”¾ç®—åŠ›ï¼›ä»»åŠ¡å·²ç»æ˜¯ç»ˆæ€åˆ™è·³è¿‡è¿™æ­¥ï¼‰ï¼Œå†ç§»å…¥å›žæ”¶ç«™ï¼ˆ`deleted_at` 设æˆå½“剿—¶é—´ï¼‰ã€‚ä¸åˆ ç£ç›˜æ–‡ä»¶ã€‚
290 290
291 ```json 291 ```json
292 {"id": "xxx", "deleted": true} 292 {"id": "xxx", "deleted": true}
@@ -338,7 +338,7 @@ query 傿•°ï¼ˆå…¨éƒ¨å¯é€‰ï¼‰ï¼š @@ -338,7 +338,7 @@ query 傿•°ï¼ˆå…¨éƒ¨å¯é€‰ï¼‰ï¼š
338 } 338 }
339 ``` 339 ```
340 340
341 -`batch-cancel` 对批é‡é‡Œæ··è¿›çš„ `running` 任务一样会触å‘硬é‡å¯ï¼ˆè§ä¸Šé¢å•æ¡ cancel 的说明)——如果批é‡ç›®æ ‡é‡Œå¯èƒ½åŒ…嫿­£åœ¨è¿è¡Œçš„任务,先用筛选æ¡ä»¶æŠŠ `status` é™å®šæˆ `queued`,é¿å…è¯¯åœæ­£åœ¨è·‘的任务。 341 +`batch-cancel` å’Œ `batch-delete`(内部也会先 cancel)对批é‡é‡Œæ··è¿›çš„ `running` 任务一样会触å‘硬é‡å¯ï¼ˆè§ä¸Šé¢å•æ¡ cancel 的说明)——如果批é‡ç›®æ ‡é‡Œå¯èƒ½åŒ…嫿­£åœ¨è¿è¡Œçš„任务,先用筛选æ¡ä»¶æŠŠ `status` é™å®šæˆ `queued`,é¿å…è¯¯åœæ­£åœ¨è·‘的任务。
342 342
343 --- 343 ---
344 344
h3-console/server.py
@@ -622,8 +622,16 @@ async def list_videos(limit: int = 20, offset: int = 0, trash: bool = False, q: @@ -622,8 +622,16 @@ async def list_videos(limit: int = 20, offset: int = 0, trash: bool = False, q:
622 return {"jobs": [_row_view(r) for r in rows], "total": total} 622 return {"jobs": [_row_view(r) for r in rows], "total": total}
623 623
624 624
625 -def _delete_job(jid: str) -> dict:  
626 - """真正干活的软删除逻辑,单条/批量共用,理由同 _cancel_job。""" 625 +async def _delete_job(jid: str) -> dict:
  626 + """真正干活的删除逻辑,单条/批量共用。先调 _cancel_job 让底层任务真正停掉、释放算力,
  627 + 任务已经是终态(done/failed/cancelled)时 _cancel_job 会 409——这种情况没算力可释放,
  628 + 忽略这个 409 继续走软删除;其它取消失败(比如后端连不上)直接把错误透传出去,不能在
  629 + 算力还没释放的时候就把任务从列表里挪走,制造"删了但其实还在跑"的假象。"""
  630 + try:
  631 + await _cancel_job(jid)
  632 + except HTTPException as e:
  633 + if e.status_code != 409:
  634 + raise
627 with db() as con: 635 with db() as con:
628 cur = con.execute("UPDATE jobs SET deleted_at=? WHERE id=? AND deleted_at IS NULL", (time.time(), jid)) 636 cur = con.execute("UPDATE jobs SET deleted_at=? WHERE id=? AND deleted_at IS NULL", (time.time(), jid))
629 if cur.rowcount == 0: 637 if cur.rowcount == 0:
@@ -633,8 +641,8 @@ def _delete_job(jid: str) -&gt; dict: @@ -633,8 +641,8 @@ def _delete_job(jid: str) -&gt; dict:
633 641
634 @api.delete("/v1/videos/{jid}") 642 @api.delete("/v1/videos/{jid}")
635 async def delete_video(jid: str): 643 async def delete_video(jid: str):
636 - """软删除:只挪出主列表,不碰 gateway/api.py 上的任务本身(可能还在跑),不删磁盘文件。"""  
637 - return _delete_job(jid) 644 + """删除:先真正取消底层任务(释放算力),再移入回收站。不删磁盘文件。"""
  645 + return await _delete_job(jid)
638 646
639 647
640 @api.post("/v1/videos/{jid}/restore") 648 @api.post("/v1/videos/{jid}/restore")
@@ -702,14 +710,13 @@ async def batch_cancel_videos(body: dict): @@ -702,14 +710,13 @@ async def batch_cancel_videos(body: dict):
702 710
703 @api.post("/v1/videos/batch-delete") 711 @api.post("/v1/videos/batch-delete")
704 async def batch_delete_videos(body: dict): 712 async def batch_delete_videos(body: dict):
705 - """批量软删除(移入回收站),语义同单条 DELETE /v1/videos/{jid}——只是把任务从列表里挪走,  
706 - 不取消底层计算;真要连底层任务一起停掉,配合上面的 batch-cancel 一起调(谁先谁后不影响  
707 - 结果,两个操作互不冲突)。""" 713 + """批量删除,语义同单条 DELETE /v1/videos/{jid}:逐条先取消底层任务(释放算力),
  714 + 再移入回收站。"""
708 ids = _resolve_batch_targets(body) 715 ids = _resolve_batch_targets(body)
709 succeeded, failed = [], [] 716 succeeded, failed = [], []
710 for jid in ids: 717 for jid in ids:
711 try: 718 try:
712 - succeeded.append(_delete_job(jid)["id"]) 719 + succeeded.append((await _delete_job(jid))["id"])
713 except HTTPException as e: 720 except HTTPException as e:
714 failed.append({"id": jid, "error": e.detail}) 721 failed.append({"id": jid, "error": e.detail})
715 return {"requested": len(ids), "succeeded": succeeded, "failed": failed} 722 return {"requested": len(ids), "succeeded": succeeded, "failed": failed}
@@ -919,6 +926,39 @@ async def newtask_page(): @@ -919,6 +926,39 @@ async def newtask_page():
919 return render_index() 926 return render_index()
920 927
921 928
  929 +# 2026-09-09:列表/详情页缩略图之前直接用参考图原图(1-2MB/张),加缓存头只解决了
  930 +# "重复"下载,首次打开还是要传那么大。这里给图片单独开一条缩略图路径——注意跟 /assets/*
  931 +# 是两个独立的东西:job req 里存的 first_frame_url/reference_image_urls 一个字节都不动,
  932 +# 那是喂给生成模型的真实输入,缩略图只是前端展示用,绝不能让前端不小心把这条 URL 存回
  933 +# req 里、拿小图去生成视频。thumb_url() 是纯字符串规则从 /assets/{filename} 反推
  934 +# /thumbs/{filename},不改 upload_asset 返回值,老任务的原始 URL 一样能配出缩略图。
  935 +# 首次访问时惰性生成、落盘缓存,之后跟原图一样标 immutable——参考图传上来就不会再变。
  936 +THUMB_DIR = ASSETS / "_thumbs"
  937 +THUMB_DIR.mkdir(exist_ok=True)
  938 +THUMB_MAX_SIDE = 320 # 列表缩略图 88x118 CSS px、详情页预览 160x160 CSS px,320 留够 2x retina
  939 +
  940 +
  941 +@router.get("/thumbs/{filename}")
  942 +async def get_thumb(filename: str):
  943 + src = (ASSETS / filename).resolve()
  944 + if ASSETS.resolve() not in src.parents or not src.is_file():
  945 + raise HTTPException(404, "无此文件")
  946 + thumb_path = THUMB_DIR / (src.stem + ".jpg")
  947 + if not thumb_path.exists():
  948 + try:
  949 + img = Image.open(src)
  950 + img.thumbnail((THUMB_MAX_SIDE, THUMB_MAX_SIDE))
  951 + tmp_path = thumb_path.with_suffix(".tmp")
  952 + img.convert("RGB").save(tmp_path, "JPEG", quality=82)
  953 + tmp_path.replace(thumb_path) # 原子改名,防止并发首次请求读到写一半的文件
  954 + except Exception:
  955 + # 不是图片能解码的文件(参考视频/音频也存在 assets/ 里,理论上不会被前端拿来
  956 + # 调这个接口,这里只是防御性兜底)——原样把源文件当"缩略图"返回。
  957 + return FileResponse(src, headers={"Cache-Control": "public, max-age=31536000, immutable"})
  958 + return FileResponse(thumb_path, media_type="image/jpeg",
  959 + headers={"Cache-Control": "public, max-age=31536000, immutable"})
  960 +
  961 +
922 # BASE_PATH 留空时这个前缀是空字符串,router 上的路径原样挂到 app 根上,行为跟以前 962 # BASE_PATH 留空时这个前缀是空字符串,router 上的路径原样挂到 app 根上,行为跟以前
923 # 直接用 @app.xxx 一模一样;非空时(比如 "/console")整体套一层前缀,一次性生效, 963 # 直接用 @app.xxx 一模一样;非空时(比如 "/console")整体套一层前缀,一次性生效,
924 # 不用每个路由自己拼。 964 # 不用每个路由自己拼。
h3-console/static/index.html
@@ -296,6 +296,15 @@ function h3Token() { @@ -296,6 +296,15 @@ function h3Token() {
296 } 296 }
297 return t; 297 return t;
298 } 298 }
  299 +// 2026-09-09:只重写本机 /assets/ 的上传文件到服务端缩略图路径(/thumbs/{filename},见
  300 +// server.py),外部图床 URL("外部 URL 不经过 /v1/assets 转存"那条路径)原样返回——
  301 +// 后端没有那些文件,生不出缩略图。绝不改 req 里存的原始 URL 本身,只用于展示。
  302 +function thumbUrl(u) {
  303 + if (!u) return u;
  304 + const i = u.indexOf('/assets/');
  305 + if (i === -1) return u;
  306 + return u.slice(0, i) + '/thumbs/' + u.slice(i + '/assets/'.length);
  307 +}
299 function h3FileUrl(path) { 308 function h3FileUrl(path) {
300 // <video src>/<a href download> 是浏览器原生请求,加不上 Authorization 头, 309 // <video src>/<a href download> 是浏览器原生请求,加不上 Authorization 头,
301 // 只能把 token 拼进查询串——server.py 的 _require_token 两种都认。 310 // 只能把 token 拼进查询串——server.py 的 _require_token 两种都认。
@@ -842,8 +851,8 @@ function jobKindLabel(req) { @@ -842,8 +851,8 @@ function jobKindLabel(req) {
842 } 851 }
843 852
844 // 已完成的任务直接拿结果视频当封面(preload=metadata 只取第一帧,不下载整段); 853 // 已完成的任务直接拿结果视频当封面(preload=metadata 只取第一帧,不下载整段);
845 -// 还没完成的用输入的首帧图/参考图占位,好歹能看出这条任务在做什么;两者都没有  
846 -// (纯文生视频还没跑完)就退化成显示任务类型文字。 854 +// 还没完成的用输入的首帧图/参考图占位(缩略图版本,见 thumbUrl),好歹能看出这条任务在
  855 +// 做什么;两者都没有(纯文生视频还没跑完)就退化成显示任务类型文字。
847 // ponytail: 列表里同时出现很多条"已完成"任务时,浏览器会并发发起同等数量的 856 // ponytail: 列表里同时出现很多条"已完成"任务时,浏览器会并发发起同等数量的
848 // metadata 探测请求,量大可能变卡——真遇到再改成后端生成静态封面图。 857 // metadata 探测请求,量大可能变卡——真遇到再改成后端生成静态封面图。
849 function jobThumbHtml(j) { 858 function jobThumbHtml(j) {
@@ -853,7 +862,7 @@ function jobThumbHtml(j) { @@ -853,7 +862,7 @@ function jobThumbHtml(j) {
853 return `<video src="${src}" muted preload="metadata"></video>`; 862 return `<video src="${src}" muted preload="metadata"></video>`;
854 } 863 }
855 const imgUrl = req.first_frame_url || (req.reference_image_urls || [])[0]; 864 const imgUrl = req.first_frame_url || (req.reference_image_urls || [])[0];
856 - if (imgUrl) return `<img src="${imgUrl}" loading="lazy">`; 865 + if (imgUrl) return `<img src="${thumbUrl(imgUrl)}" loading="lazy">`;
857 return `<div class="jr-thumb-empty">${jobKindLabel(req)}</div>`; 866 return `<div class="jr-thumb-empty">${jobKindLabel(req)}</div>`;
858 } 867 }
859 868
@@ -1135,7 +1144,7 @@ async function refreshDetail() { @@ -1135,7 +1144,7 @@ async function refreshDetail() {
1135 const viewThumb = (mediaHtml, label, u) => 1144 const viewThumb = (mediaHtml, label, u) =>
1136 `<span class="thumb-wrap"><span class="thumb-name" title="${thumbCap(label,u)}">${thumbCap(label,u)}</span><span class="thumb-media">${mediaHtml}</span></span>`; 1145 `<span class="thumb-wrap"><span class="thumb-name" title="${thumbCap(label,u)}">${thumbCap(label,u)}</span><span class="thumb-media">${mediaHtml}</span></span>`;
1137 const imgTags = imgs.map(([label, u]) => 1146 const imgTags = imgs.map(([label, u]) =>
1138 - viewThumb(`<a href="${u}" target="_blank" title="${label},点击查看大图"><img src="${u}" title="${label}"></a>`, label, u)).join(''); 1147 + viewThumb(`<a href="${u}" target="_blank" title="${label},点击查看大图"><img src="${thumbUrl(u)}" title="${label}"></a>`, label, u)).join('');
1139 const videoTags = (req.reference_video_urls || []).map((u, i) => 1148 const videoTags = (req.reference_video_urls || []).map((u, i) =>
1140 viewThumb(`<video controls src="${u}" title="参考视频${i+1}"></video>`, '参考视频'+(i+1), u)).join(''); 1149 viewThumb(`<video controls src="${u}" title="参考视频${i+1}"></video>`, '参考视频'+(i+1), u)).join('');
1141 const audioTags = (req.reference_audio_urls || []).map((u, i) => 1150 const audioTags = (req.reference_audio_urls || []).map((u, i) =>