diff --git a/h3-console/server.py b/h3-console/server.py index b4c63e3..1e81c9e 100644 --- a/h3-console/server.py +++ b/h3-console/server.py @@ -129,7 +129,10 @@ async def upload_asset(file: UploadFile = File(...)): if len(body) > max_bytes: raise HTTPException(413, f"文件超过 {max_bytes // 1024 // 1024}MB 上限") aid = uuid.uuid4().hex[:16] - path = ASSETS / f"{aid}{ext}" + # 原始文件名塞进存盘文件名里(aid__原名.ext),前端从 URL 就能反解出来显示—— + # 不用另外给 job req 加字段存文件名,省了一次 schema 改动。 + stem = re.sub(r"[^\w.-]+", "_", Path(file.filename or "").stem)[:60] or "file" + path = ASSETS / f"{aid}__{stem}{ext}" path.write_bytes(body) if kind == "image": try: @@ -137,7 +140,7 @@ async def upload_asset(file: UploadFile = File(...)): except Exception: path.unlink(missing_ok=True) raise HTTPException(400, "文件不是有效图片") - return {"id": aid, "url": f"{_public_base()}/assets/{aid}{ext}", "kind": kind} + return {"id": aid, "url": f"{_public_base()}/assets/{aid}__{stem}{ext}", "kind": kind} def _public_base() -> str: diff --git a/h3-console/static/index.html b/h3-console/static/index.html index bef9a51..6ed6eba 100644 --- a/h3-console/static/index.html +++ b/h3-console/static/index.html @@ -48,14 +48,19 @@ button.submit { margin-top: 12px; padding: 10px 20px; background: #4a7; color: white; border: none; border-radius: 6px; cursor: pointer; } button.submit:disabled { opacity: .5; cursor: default; } .hint { font-size: 12px; opacity: .65; margin: 6px 0 4px; line-height: 1.5; } - .thumbrow { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; align-items: flex-start; } - .thumbrow img { height: 60px; border-radius: 4px; } - .thumbrow video { height: 220px; max-width: 100%; border-radius: 4px; } - .thumbrow audio { height: 32px; width: 220px; } - .thumb-wrap { position: relative; display: inline-block; } - .thumb-del { position: absolute; top: -6px; right: -6px; width: 20px; height: 20px; padding: 0; - border-radius: 50%; border: none; background: #c33; color: #fff; font-size: 13px; line-height: 1; - cursor: pointer; } + .thumbrow { display: flex; gap: 20px; flex-wrap: wrap; margin-top: 12px; align-items: flex-start; } + .thumbrow img { height: 160px; width: 160px; object-fit: cover; border-radius: 8px; + border: 1px solid rgba(128,128,128,.3); display: block; } + .thumbrow video { height: 200px; width: 340px; max-width: 100%; object-fit: cover; + border-radius: 8px; border: 1px solid rgba(128,128,128,.3); display: block; background: #000; } + .thumbrow audio { height: 40px; width: 340px; max-width: 100%; display: block; } + .thumb-wrap { display: inline-flex; flex-direction: column; gap: 6px; max-width: 340px; } + .thumb-media { position: relative; display: inline-block; line-height: 0; } + .thumb-name { font-size: 12px; opacity: .7; max-width: 100%; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } + .thumb-del { position: absolute; top: -8px; right: -8px; width: 24px; height: 24px; padding: 0; + border-radius: 50%; border: none; background: #c33; color: #fff; font-size: 15px; line-height: 1; + cursor: pointer; box-shadow: 0 1px 4px rgba(0,0,0,.35); } .thumb-del:hover { background: #a22; } .status-done { color: #2a7; } .status-failed { color: #c33; } .status-running { color: #a70; } .status-queued { color: #777; } .status-cancelled { color: #999; } .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) { return await r.json(); } -// 每个预览项都包一层 .thumb-wrap 挂一个 × 删除按钮——上传上去选错了/传多了, -// 之前只能整个表单清空重来,2026-08-24 用户要求能单个删。 -function wrapWithDelete(mediaEl, onDelete) { +// server.py 把原始文件名塞进了存盘文件名里(__<原名>.ext),这里直接从 URL 反解出来 +// 显示——不用单独维护一份 filename 状态,旧任务(改动前上传的,URL 里没有 __ 分段)会 +// 退化成显示 uuid 文件名,不算错误,只是没有原名可用。 +function filenameFromUrl(u) { + try { + const base = decodeURIComponent(u.split('/').pop().split('?')[0]); + const m = base.match(/^[0-9a-f]{16}__(.+)$/); + return m ? m[1] : base; + } catch (e) { return u; } +} + +// 每个预览项都包一层 .thumb-wrap,媒体本体再包一层 .thumb-media 挂 × 删除按钮, +// 下面接一行文件名 caption——上传上去选错了/传多了,之前只能整个表单清空重来, +// 2026-08-24 用户要求能单个删;文件名是 2026-08-24 补的,之前只有个位置标签看不出传的是哪个文件。 +function wrapWithDelete(mediaEl, onDelete, caption) { const wrap = document.createElement('span'); wrap.className = 'thumb-wrap'; + const mediaWrap = document.createElement('span'); + mediaWrap.className = 'thumb-media'; const del = document.createElement('button'); del.type = 'button'; del.className = 'thumb-del'; del.textContent = '×'; del.title = '删除'; del.onclick = onDelete; - wrap.appendChild(mediaEl); wrap.appendChild(del); + mediaWrap.appendChild(mediaEl); mediaWrap.appendChild(del); + wrap.appendChild(mediaWrap); + if (caption) { + const cap = document.createElement('span'); + cap.className = 'thumb-name'; cap.textContent = caption; cap.title = caption; + wrap.appendChild(cap); + } return wrap; } @@ -353,7 +378,7 @@ function renderFl2vaThumbs() { uploaded[key] = null; document.getElementById(key).value = ''; // 清掉file input,不然重选同一个文件不会触发change renderFl2vaThumbs(); - })); + }, `${label} · ${filenameFromUrl(u)}`)); }); } @@ -371,10 +396,11 @@ function renderRefThumbs() { const box = document.getElementById('ref2va-thumbs'); box.innerHTML = ''; uploaded['ref2va-refs'].forEach((u, i) => { const img = document.createElement('img'); img.src = u; + const label = `参考图${i+1}`; img.title = label; box.appendChild(wrapWithDelete(img, () => { uploaded['ref2va-refs'].splice(i, 1); renderRefThumbs(); - })); + }, `${label} · ${filenameFromUrl(u)}`)); }); } @@ -397,10 +423,12 @@ function renderTags(elId, stateKey) { uploaded[stateKey].forEach((u, i) => { const el = document.createElement(isVideo ? 'video' : 'audio'); el.src = u; el.controls = true; + const label = (isVideo ? '参考视频' : '参考音频') + (i + 1); + el.title = label; box.appendChild(wrapWithDelete(el, () => { uploaded[stateKey].splice(i, 1); renderTags(elId, stateKey); - })); + }, `${label} · ${filenameFromUrl(u)}`)); }); } @@ -799,10 +827,17 @@ async function refreshDetail() { if (req.first_frame_url) imgs.push(['首帧', req.first_frame_url]); if (req.last_frame_url) imgs.push(['尾帧', req.last_frame_url]); (req.reference_image_urls || []).forEach((u, i) => imgs.push(['参考图'+(i+1), u])); - // 图片点开看大图,参考视频/音频跟生成结果的视频一样用原生播放器直接预览,不再只显示文件名。 - const imgTags = imgs.map(([label, u]) => ``).join(''); - const videoTags = (req.reference_video_urls || []).map((u, i) => ``).join(''); - const audioTags = (req.reference_audio_urls || []).map((u, i) => ``).join(''); + // 图片点开看大图,参考视频/音频跟生成结果的视频一样用原生播放器直接预览;每个媒体下面 + // 再带一行文件名(从上传时 server.py 塞进 URL 里的原名反解出来,见 filenameFromUrl)。 + const thumbCap = (label, u) => `${label} · ${filenameFromUrl(u)}`.replace(/ + `${mediaHtml}${thumbCap(label,u)}`; + const imgTags = imgs.map(([label, u]) => + viewThumb(``, label, u)).join(''); + const videoTags = (req.reference_video_urls || []).map((u, i) => + viewThumb(``, '参考视频'+(i+1), u)).join(''); + const audioTags = (req.reference_audio_urls || []).map((u, i) => + viewThumb(``, '参考音频'+(i+1), u)).join(''); if (imgTags || videoTags) refThumbs += `
${imgTags}${videoTags}
`; if (audioTags) refThumbs += `
${audioTags}
`;