Files
hair/static/test_interface3.html
xslandCursor e7b62f2b2e perf(接口2): 稳定混跑耗时至12s内 —— ComfyUI插队 + CLIP挪CPU + 提示词全局统一
问题:接口2 与接口3/5 乱序调用时耗时抖动(最差 15~22s)。两个根因:
1. GPU 24G 常驻 21.4G,Flux-2(3.9G) 无法完全驻留显存,每次采样动态换页,
   速度随空闲显存波动(2s~8s);
2. ComfyUI 单队列 FIFO,接口2 排在接口3/5 批量任务后面。

改动:
- hairline/comfyui.py: run() 新增 front 参数,/prompt 带 "front": true 插队到队列最前;
  redraw.py 透传;service.py 接口2 三处调用(女重绘 + 男有/无遮罩)传 front=True,
  接口3/5 仍走普通队列。
- add_hair.json / 0716add-hair-api.json: 节点61 CLIPLoader device default→cpu。
  qwen CLIP(4G) 不再占显存(文本条件缓存常年命中),ComfyUI 显存 8.8G→4.5G,
  Flux-2 完全驻留,采样稳定 ~3-5s。代价:换 prompt 后首次请求 CPU 编码 ~11s(一次性)。
- 提示词全局统一为「填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜」:
  app.py 4处默认值、service.py _REDRAW_PROMPT、redraw.py _DEFAULT_PROMPT、
  4个工作流节点60内置文案、测试页(test_interface2/3/7/12/12_final)、local_test。
  任何两个不同 prompt 交替提交都会打爆 CLIP 编码缓存(--cache-classic 只存最近一次),
  之前测试页旧文案与服务端不一致导致交替测试每次 +11s。
- app.py: 接口7 /api/v1/hair/grow-v2 下线(业务弃用;add_hair2.json 的 Klein-9b
  会把常驻 Klein-4b 挤出显存)。保留 stub 返回 1007 明确报错,避免裸 404。

实测(1024 档):接口2女 8.5~10s、接口2男 ~5s、接口3 ~7-10s,交替混跑无尖刺。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 01:01:48 +08:00

227 lines
12 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>接口3 — B端生发 测试页</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 6px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 24px; }
.card { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
.card-header { font-weight: 700; font-size: 14px; padding: 14px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; display: flex; justify-content: space-between; align-items: center; }
.card-body { padding: 18px; }
.upload-group { margin-bottom: 14px; }
.upload-group .label { font-weight: 700; font-size: 13px; color: #374151; margin-bottom: 6px; }
.upload-group .label .tag { font-size: 11px; padding: 2px 8px; border-radius: 10px; margin-left: 6px; }
.upload-group .label .tag.required { background: #fee2e2; color: #991b1b; }
.upload-group input[type=file] { width: 100%; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
.upload-row { display: flex; gap: 12px; align-items: flex-start; flex-wrap: wrap; }
.upload-row .upload-group { flex: 1; min-width: 250px; }
.btn { padding: 10px 28px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; transition: .2s; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
.btn-outline:hover { background: #f9fafb; }
.hint { font-size: 12px; color: #9ca3af; margin-top: 8px; }
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
.status.info { background: #dbeafe; color: #1e40af; display: block; }
.status.error { background: #fee2e2; color: #991b1b; display: block; }
.status.success { background: #d1fae5; color: #065f46; display: block; }
.results-layout { display: flex; gap: 24px; }
.col-main { flex: 1.5; min-width: 0; }
.col-side { flex: 1; min-width: 0; }
.preview-row { display: flex; gap: 16px; }
.preview-row > div { flex: 1; text-align: center; }
.preview-row .thumb { background: #222; border-radius: 8px; overflow: hidden; display: inline-block; line-height: 0; }
.preview-row .thumb img { max-width: 100%; max-height: 350px; object-fit: contain; }
.preview-row .caption { font-size: 11px; color: #9ca3af; margin-top: 6px; }
.na { color: #d1d5db; font-size: 12px; padding: 40px 20px; background: #f9fafb; border-radius: 6px; }
.result-field { margin-bottom: 14px; }
.result-field .name { font-size: 12px; font-weight: 600; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: .5px; }
.badge { display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 600; background: #dbeafe; color: #1e40af; }
/* blend */
.blend-container { position: relative; width: 100%; background: #222; border-radius: 8px; overflow: hidden; line-height: 0; }
.blend-container img { display: block; width: 100%; max-height: 500px; object-fit: contain; }
.blend-bottom { position: relative; z-index: 1; }
.blend-top { position: absolute; top: 0; left: 0; z-index: 2; transition: opacity 0.06s; }
.blend-placeholder { color: #9ca3af; font-size: 12px; padding: 80px 20px; background: #f9fafb; line-height: 1.5; }
.blend-controls { display: flex; align-items: center; gap: 10px; margin-top: 14px; justify-content: center; }
.blend-controls input[type=range] { width: 220px; accent-color: #2563eb; }
.blend-label { font-size: 12px; color: #6b7280; font-weight: 600; }
.blend-value { font-size: 13px; font-weight: 600; color: #374151; min-width: 42px; text-align: center; background: #f3f4f6; border-radius: 6px; padding: 2px 8px; }
.json-panel { max-height: 550px; overflow: auto; }
.json-content { padding: 14px 16px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
.hidden { display: none !important; }
.tag-new { background: #059669; color: #fff; font-size: 10px; padding: 1px 6px; border-radius: 8px; margin-left: 4px; }
@media (max-width: 800px) { .results-layout, .preview-row { flex-direction: column; } }
</style>
<script src="/static/img_downscale.js?v=2"></script>
</head>
<body>
<div class="container">
<h1>🏥 接口3 — B端生发 测试</h1>
<p class="subtitle">POST /api/v1/hair/grow-b &nbsp;|&nbsp; 上传马克笔划线图 → 生发效果图</p>
<div class="card">
<div class="card-body">
<div class="upload-group">
<div class="label">✏️ 划线图(marked_image<span class="tag required">必填</span></div>
<input type="file" id="markedFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png">
</div>
<div class="upload-group" style="margin-top:14px">
<div class="label">🪄 画发际线(use_mask<span class="tag-new">NEW</span></div>
<select id="useMask" style="padding:8px 12px;border:2px solid #ddd;border-radius:8px;font-size:14px;background:#fff;min-width:280px">
<option value="true" selected>✅ 画发际线(默认)— 检测划线并生成遮罩</option>
<option value="false">⬜ 不画发际线 — 跳过划线检测,直接送划线图(测试对比用)</option>
</select>
<div class="hint">false 时跳过划线检测、直接送划线图,用于对比测试</div>
</div>
<div class="upload-group" style="margin-top:14px">
<div class="label">💬 提示词(prompt</div>
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
</div>
<div style="margin-top:14px;display:flex;gap:12px;align-items:center">
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button>
</div>
<div class="hint">JPG/PNG &nbsp;|&nbsp; 用马克笔在用户照片额头画发际线后拍照上传 &nbsp;|&nbsp; 不再需要原始照片</div>
<div id="statusBar" class="status hidden"></div>
</div>
</div>
<div class="results-layout hidden" id="resultsArea">
<div class="col-main">
<div class="card">
<div class="card-header"><span>🔄 原图 / 生发图 叠加对比 &nbsp;</span><span class="badge" id="hairlineType" style="display:none"></span></div>
<div class="card-body" style="text-align:center">
<div class="blend-container" id="blendContainer">
<img id="blendBottom" class="blend-bottom" alt="原图(划线图)" style="display:none">
<img id="blendTop" class="blend-top" alt="生发效果图" style="display:none;opacity:0.5">
<div class="blend-placeholder" id="blendPlaceholder"></div>
</div>
<div class="blend-controls hidden" id="blendControls">
<span class="blend-label">原图</span>
<input type="range" id="blendSlider" min="0" max="1" step="0.01" value="0.5">
<span class="blend-label">生发图</span>
<span class="blend-value" id="blendValue">0.50</span>
</div>
</div>
</div>
</div>
<div class="col-side">
<div class="card">
<div class="card-header"><span>📋 JSON</span><button class="btn btn-outline btn-sm" onclick="copyJson()">📋 复制</button></div>
<div class="json-panel"><pre class="json-content" id="jsonContent"></pre></div>
</div>
</div>
</div>
</div>
<script>
const API_BASE = window.location.origin;
function $(id) { return document.getElementById(id); }
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
async function submitTest() {
let mf = $('markedFile').files[0];
if (!mf) { setStatus('请选择划线图', 'error'); return; }
mf = await window.downscaleImageFile(mf);
const markedUrl = URL.createObjectURL(mf);
// 先显示原图
$('blendPlaceholder').style.display = 'none';
$('blendBottom').src = markedUrl;
$('blendBottom').style.display = 'block';
$('blendTop').style.display = 'none';
$('blendControls').classList.add('hidden');
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ ...';
setStatus('请求中(生发需要数十秒)...', 'info'); $('resultsArea').classList.remove('hidden');
const fd = new FormData();
fd.append('marked_image_file', mf);
fd.append('use_mask', $('useMask').value);
fd.append('prompt', $('promptInput').value);
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/hair/grow-b', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
const d = json.data;
if (d.hairline_type) {
$('hairlineType').textContent = '发际线: ' + d.hairline_type;
$('hairlineType').style.display = 'inline-block';
}
const grownSrc = resolveImgSrc(d.hair_growth_image_url, d.hair_growth_image_base64, 'image/jpeg');
if (grownSrc) {
$('blendTop').src = grownSrc;
$('blendTop').style.display = 'block';
$('blendTop').style.opacity = $('blendSlider').value;
$('blendControls').classList.remove('hidden');
$('blendValue').textContent = parseFloat($('blendSlider').value).toFixed(2);
} else {
$('blendTop').style.display = 'none';
$('blendControls').classList.add('hidden');
setStatus('⚠ 生发图未返回 — ComfyUI 未就绪或生成失败', 'info');
return;
}
setStatus('✅ 成功 (' + _elapsed + 's) &nbsp;|&nbsp; 发际线类型: ' + (d.hairline_type || '—'), 'success');
} else {
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
$('blendTop').style.display = 'none';
}
} catch(e) {
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 提交'; }
}
function clearResults() {
$('resultsArea').classList.add('hidden'); $('statusBar').className='status hidden';
$('markedFile').value=''; $('jsonContent').textContent='';
$('blendBottom').src = ''; $('blendBottom').style.display = 'none';
$('blendTop').src = ''; $('blendTop').style.display = 'none';
$('blendPlaceholder').style.display = 'block';
$('blendControls').classList.add('hidden');
$('blendSlider').value = 0.5; $('blendValue').textContent = '0.50';
$('hairlineType').textContent = ''; $('hairlineType').style.display = 'none';
}
$('blendSlider').addEventListener('input', function() {
$('blendTop').style.opacity = this.value;
$('blendValue').textContent = parseFloat(this.value).toFixed(2);
});
function copyJson() {
navigator.clipboard.writeText($('jsonContent').textContent).then(() => {
const b = event.target, o = b.textContent; b.textContent = '✅ 已复制'; setTimeout(() => b.textContent = o, 1500);
});
}
</script>
</body>
</html>