Commit 8b1a0d221e51e061ea7b59e199e4314a920d5c0f
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
Showing
3 changed files
with
66 additions
and
17 deletions
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) -> dict: | @@ -633,8 +641,8 @@ def _delete_job(jid: str) -> 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) => |