Commit 47cf8fbd153698cd513a9caf9c6214b5f1ee50c0
1 parent
83626817
h3-console: 上传素材保留原始文件名,预览缩略图下加文件名 caption
之前上传后只显示 uuid 文件名,传多个参考图/视频时分不清哪个是哪个。server.py 把原始 文件名塞进存盘文件名(<aid>__<原名>.ext),前端从 URL 反解出来显示在缩略图下方,旧任务 (改动前上传的,URL里没有__分段)会退化成显示uuid,不算错误只是没有原名可用。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
2 changed files
with
59 additions
and
21 deletions
h3-console/server.py
| ... | ... | @@ -129,7 +129,10 @@ async def upload_asset(file: UploadFile = File(...)): |
| 129 | 129 | if len(body) > max_bytes: |
| 130 | 130 | raise HTTPException(413, f"文件超过 {max_bytes // 1024 // 1024}MB 上限") |
| 131 | 131 | aid = uuid.uuid4().hex[:16] |
| 132 | - path = ASSETS / f"{aid}{ext}" | |
| 132 | + # 原始文件名塞进存盘文件名里(aid__原名.ext),前端从 URL 就能反解出来显示—— | |
| 133 | + # 不用另外给 job req 加字段存文件名,省了一次 schema 改动。 | |
| 134 | + stem = re.sub(r"[^\w.-]+", "_", Path(file.filename or "").stem)[:60] or "file" | |
| 135 | + path = ASSETS / f"{aid}__{stem}{ext}" | |
| 133 | 136 | path.write_bytes(body) |
| 134 | 137 | if kind == "image": |
| 135 | 138 | try: |
| ... | ... | @@ -137,7 +140,7 @@ async def upload_asset(file: UploadFile = File(...)): |
| 137 | 140 | except Exception: |
| 138 | 141 | path.unlink(missing_ok=True) |
| 139 | 142 | raise HTTPException(400, "文件不是有效图片") |
| 140 | - return {"id": aid, "url": f"{_public_base()}/assets/{aid}{ext}", "kind": kind} | |
| 143 | + return {"id": aid, "url": f"{_public_base()}/assets/{aid}__{stem}{ext}", "kind": kind} | |
| 141 | 144 | |
| 142 | 145 | |
| 143 | 146 | def _public_base() -> str: | ... | ... |
h3-console/static/index.html
| ... | ... | @@ -48,14 +48,19 @@ |
| 48 | 48 | button.submit { margin-top: 12px; padding: 10px 20px; background: #4a7; color: white; border: none; border-radius: 6px; cursor: pointer; } |
| 49 | 49 | button.submit:disabled { opacity: .5; cursor: default; } |
| 50 | 50 | .hint { font-size: 12px; opacity: .65; margin: 6px 0 4px; line-height: 1.5; } |
| 51 | - .thumbrow { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; align-items: flex-start; } | |
| 52 | - .thumbrow img { height: 60px; border-radius: 4px; } | |
| 53 | - .thumbrow video { height: 220px; max-width: 100%; border-radius: 4px; } | |
| 54 | - .thumbrow audio { height: 32px; width: 220px; } | |
| 55 | - .thumb-wrap { position: relative; display: inline-block; } | |
| 56 | - .thumb-del { position: absolute; top: -6px; right: -6px; width: 20px; height: 20px; padding: 0; | |
| 57 | - border-radius: 50%; border: none; background: #c33; color: #fff; font-size: 13px; line-height: 1; | |
| 58 | - cursor: pointer; } | |
| 51 | + .thumbrow { display: flex; gap: 20px; flex-wrap: wrap; margin-top: 12px; align-items: flex-start; } | |
| 52 | + .thumbrow img { height: 160px; width: 160px; object-fit: cover; border-radius: 8px; | |
| 53 | + border: 1px solid rgba(128,128,128,.3); display: block; } | |
| 54 | + .thumbrow video { height: 200px; width: 340px; max-width: 100%; object-fit: cover; | |
| 55 | + border-radius: 8px; border: 1px solid rgba(128,128,128,.3); display: block; background: #000; } | |
| 56 | + .thumbrow audio { height: 40px; width: 340px; max-width: 100%; display: block; } | |
| 57 | + .thumb-wrap { display: inline-flex; flex-direction: column; gap: 6px; max-width: 340px; } | |
| 58 | + .thumb-media { position: relative; display: inline-block; line-height: 0; } | |
| 59 | + .thumb-name { font-size: 12px; opacity: .7; max-width: 100%; overflow: hidden; | |
| 60 | + text-overflow: ellipsis; white-space: nowrap; } | |
| 61 | + .thumb-del { position: absolute; top: -8px; right: -8px; width: 24px; height: 24px; padding: 0; | |
| 62 | + border-radius: 50%; border: none; background: #c33; color: #fff; font-size: 15px; line-height: 1; | |
| 63 | + cursor: pointer; box-shadow: 0 1px 4px rgba(0,0,0,.35); } | |
| 59 | 64 | .thumb-del:hover { background: #a22; } |
| 60 | 65 | .status-done { color: #2a7; } .status-failed { color: #c33; } .status-running { color: #a70; } .status-queued { color: #777; } .status-cancelled { color: #999; } |
| 61 | 66 | .err { color: #c33; font-size: 13px; margin-top: 8px; white-space: pre-wrap; font-family: ui-monospace, Menlo, Consolas, monospace; |
| ... | ... | @@ -331,15 +336,35 @@ async function uploadFile(file) { |
| 331 | 336 | return await r.json(); |
| 332 | 337 | } |
| 333 | 338 | |
| 334 | -// 每个预览项都包一层 .thumb-wrap 挂一个 × 删除按钮——上传上去选错了/传多了, | |
| 335 | -// 之前只能整个表单清空重来,2026-08-24 用户要求能单个删。 | |
| 336 | -function wrapWithDelete(mediaEl, onDelete) { | |
| 339 | +// server.py 把原始文件名塞进了存盘文件名里(<aid>__<原名>.ext),这里直接从 URL 反解出来 | |
| 340 | +// 显示——不用单独维护一份 filename 状态,旧任务(改动前上传的,URL 里没有 __ 分段)会 | |
| 341 | +// 退化成显示 uuid 文件名,不算错误,只是没有原名可用。 | |
| 342 | +function filenameFromUrl(u) { | |
| 343 | + try { | |
| 344 | + const base = decodeURIComponent(u.split('/').pop().split('?')[0]); | |
| 345 | + const m = base.match(/^[0-9a-f]{16}__(.+)$/); | |
| 346 | + return m ? m[1] : base; | |
| 347 | + } catch (e) { return u; } | |
| 348 | +} | |
| 349 | + | |
| 350 | +// 每个预览项都包一层 .thumb-wrap,媒体本体再包一层 .thumb-media 挂 × 删除按钮, | |
| 351 | +// 下面接一行文件名 caption——上传上去选错了/传多了,之前只能整个表单清空重来, | |
| 352 | +// 2026-08-24 用户要求能单个删;文件名是 2026-08-24 补的,之前只有个位置标签看不出传的是哪个文件。 | |
| 353 | +function wrapWithDelete(mediaEl, onDelete, caption) { | |
| 337 | 354 | const wrap = document.createElement('span'); |
| 338 | 355 | wrap.className = 'thumb-wrap'; |
| 356 | + const mediaWrap = document.createElement('span'); | |
| 357 | + mediaWrap.className = 'thumb-media'; | |
| 339 | 358 | const del = document.createElement('button'); |
| 340 | 359 | del.type = 'button'; del.className = 'thumb-del'; del.textContent = '×'; del.title = '删除'; |
| 341 | 360 | del.onclick = onDelete; |
| 342 | - wrap.appendChild(mediaEl); wrap.appendChild(del); | |
| 361 | + mediaWrap.appendChild(mediaEl); mediaWrap.appendChild(del); | |
| 362 | + wrap.appendChild(mediaWrap); | |
| 363 | + if (caption) { | |
| 364 | + const cap = document.createElement('span'); | |
| 365 | + cap.className = 'thumb-name'; cap.textContent = caption; cap.title = caption; | |
| 366 | + wrap.appendChild(cap); | |
| 367 | + } | |
| 343 | 368 | return wrap; |
| 344 | 369 | } |
| 345 | 370 | |
| ... | ... | @@ -353,7 +378,7 @@ function renderFl2vaThumbs() { |
| 353 | 378 | uploaded[key] = null; |
| 354 | 379 | document.getElementById(key).value = ''; // 清掉file input,不然重选同一个文件不会触发change |
| 355 | 380 | renderFl2vaThumbs(); |
| 356 | - })); | |
| 381 | + }, `${label} · ${filenameFromUrl(u)}`)); | |
| 357 | 382 | }); |
| 358 | 383 | } |
| 359 | 384 | |
| ... | ... | @@ -371,10 +396,11 @@ function renderRefThumbs() { |
| 371 | 396 | const box = document.getElementById('ref2va-thumbs'); box.innerHTML = ''; |
| 372 | 397 | uploaded['ref2va-refs'].forEach((u, i) => { |
| 373 | 398 | const img = document.createElement('img'); img.src = u; |
| 399 | + const label = `参考图${i+1}`; img.title = label; | |
| 374 | 400 | box.appendChild(wrapWithDelete(img, () => { |
| 375 | 401 | uploaded['ref2va-refs'].splice(i, 1); |
| 376 | 402 | renderRefThumbs(); |
| 377 | - })); | |
| 403 | + }, `${label} · ${filenameFromUrl(u)}`)); | |
| 378 | 404 | }); |
| 379 | 405 | } |
| 380 | 406 | |
| ... | ... | @@ -397,10 +423,12 @@ function renderTags(elId, stateKey) { |
| 397 | 423 | uploaded[stateKey].forEach((u, i) => { |
| 398 | 424 | const el = document.createElement(isVideo ? 'video' : 'audio'); |
| 399 | 425 | el.src = u; el.controls = true; |
| 426 | + const label = (isVideo ? '参考视频' : '参考音频') + (i + 1); | |
| 427 | + el.title = label; | |
| 400 | 428 | box.appendChild(wrapWithDelete(el, () => { |
| 401 | 429 | uploaded[stateKey].splice(i, 1); |
| 402 | 430 | renderTags(elId, stateKey); |
| 403 | - })); | |
| 431 | + }, `${label} · ${filenameFromUrl(u)}`)); | |
| 404 | 432 | }); |
| 405 | 433 | } |
| 406 | 434 | |
| ... | ... | @@ -799,10 +827,17 @@ async function refreshDetail() { |
| 799 | 827 | if (req.first_frame_url) imgs.push(['首帧', req.first_frame_url]); |
| 800 | 828 | if (req.last_frame_url) imgs.push(['尾帧', req.last_frame_url]); |
| 801 | 829 | (req.reference_image_urls || []).forEach((u, i) => imgs.push(['参考图'+(i+1), u])); |
| 802 | - // 图片点开看大图,参考视频/音频跟生成结果的视频一样用原生播放器直接预览,不再只显示文件名。 | |
| 803 | - const imgTags = imgs.map(([label, u]) => `<a href="${u}" target="_blank" title="${label},点击查看大图"><img src="${u}" title="${label}"></a>`).join(''); | |
| 804 | - const videoTags = (req.reference_video_urls || []).map((u, i) => `<video controls src="${u}" title="参考视频${i+1}"></video>`).join(''); | |
| 805 | - const audioTags = (req.reference_audio_urls || []).map((u, i) => `<audio controls src="${u}" title="参考音频${i+1}"></audio>`).join(''); | |
| 830 | + // 图片点开看大图,参考视频/音频跟生成结果的视频一样用原生播放器直接预览;每个媒体下面 | |
| 831 | + // 再带一行文件名(从上传时 server.py 塞进 URL 里的原名反解出来,见 filenameFromUrl)。 | |
| 832 | + const thumbCap = (label, u) => `${label} · ${filenameFromUrl(u)}`.replace(/</g, '<'); | |
| 833 | + const viewThumb = (mediaHtml, label, u) => | |
| 834 | + `<span class="thumb-wrap"><span class="thumb-media">${mediaHtml}</span><span class="thumb-name" title="${thumbCap(label,u)}">${thumbCap(label,u)}</span></span>`; | |
| 835 | + const imgTags = imgs.map(([label, u]) => | |
| 836 | + viewThumb(`<a href="${u}" target="_blank" title="${label},点击查看大图"><img src="${u}" title="${label}"></a>`, label, u)).join(''); | |
| 837 | + const videoTags = (req.reference_video_urls || []).map((u, i) => | |
| 838 | + viewThumb(`<video controls src="${u}" title="参考视频${i+1}"></video>`, '参考视频'+(i+1), u)).join(''); | |
| 839 | + const audioTags = (req.reference_audio_urls || []).map((u, i) => | |
| 840 | + viewThumb(`<audio controls src="${u}" title="参考音频${i+1}"></audio>`, '参考音频'+(i+1), u)).join(''); | |
| 806 | 841 | if (imgTags || videoTags) refThumbs += `<div class="thumbrow">${imgTags}${videoTags}</div>`; |
| 807 | 842 | if (audioTags) refThumbs += `<div class="thumbrow">${audioTags}</div>`; |
| 808 | 843 | ... | ... |