问题:接口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> (cherry-picked from ubuntu3090 e7b62f2;已适配 main 分支代码结构:main 无 _REDRAW_PROMPT/_REDRAW_MAX_SIDE 缩图逻辑,front=True 直接加在 _call_local_redraw / generate_grow_results 的调用点;另把 main 独有的 benchmark_*.py 里的 prompt 一并统一) Co-authored-by: Cursor <cursoragent@cursor.com>
193 lines
8.3 KiB
HTML
193 lines
8.3 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>发型补全工具</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: -apple-system, sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh; padding: 20px; }
|
||
h1 { text-align: center; margin-bottom: 20px; color: #e94560; font-size: 28px; }
|
||
.container { max-width: 1400px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||
.panel { background: #16213e; border-radius: 12px; padding: 20px; }
|
||
.panel h2 { margin-bottom: 16px; font-size: 18px; color: #e94560; }
|
||
.controls { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; align-items: center; }
|
||
.controls label { font-size: 14px; color: #aaa; }
|
||
input[type="file"] { color: #ddd; }
|
||
input[type="text"] { flex: 1; min-width: 200px; padding: 8px 12px; border-radius: 6px; border: 1px solid #444; background: #0f3460; color: #eee; font-size: 14px; }
|
||
button { padding: 10px 24px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.2s; }
|
||
.btn-upload { background: #0f3460; color: #eee; border: 1px solid #e94560; }
|
||
.btn-upload:hover { background: #1a4a7a; }
|
||
.btn-generate { background: #e94560; color: #fff; font-size: 16px; padding: 12px 36px; }
|
||
.btn-generate:hover { background: #c73650; }
|
||
.btn-generate:disabled { background: #555; cursor: not-allowed; }
|
||
.image-wrapper { display: flex; gap: 12px; flex-wrap: wrap; border: 2px dashed #444; border-radius: 8px; padding: 12px; min-height: 300px; background: #0f3460; }
|
||
.image-item { flex: 1; min-width: 200px; }
|
||
.image-item img { max-width: 100%; border-radius: 6px; }
|
||
.image-item h4 { font-size: 12px; color: #aaa; margin-bottom: 6px; }
|
||
.placeholder { color: #666; font-size: 16px; text-align: center; padding: 60px 20px; width: 100%; }
|
||
.result-area { display: flex; gap: 16px; flex-wrap: wrap; }
|
||
.result-area img { max-width: 100%; border-radius: 8px; }
|
||
.result-item { flex: 1; min-width: 250px; }
|
||
.result-item h3 { font-size: 14px; color: #aaa; margin-bottom: 8px; text-align: center; }
|
||
.loading { text-align: center; padding: 40px; color: #e94560; font-size: 18px; }
|
||
.loading .spinner { display: inline-block; width: 40px; height: 40px; border: 4px solid #333; border-top-color: #e94560; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 12px; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
.error { color: #ff6b6b; padding: 16px; background: #2a1a1a; border-radius: 8px; margin-top: 12px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>💇 发型补全工具</h1>
|
||
<div class="container">
|
||
<!-- Left: Input -->
|
||
<div class="panel">
|
||
<h2>1. 上传图片 & 遮罩</h2>
|
||
<div class="controls">
|
||
<label>人物图片:</label>
|
||
<input type="file" id="imageInput" accept="image/*" class="btn-upload">
|
||
</div>
|
||
<div class="controls">
|
||
<label>遮罩图片:</label>
|
||
<input type="file" id="maskInput" accept="image/*" class="btn-upload">
|
||
</div>
|
||
<div class="image-wrapper" id="imageWrapper">
|
||
<div class="placeholder" id="placeholder">请上传人物图片和遮罩图片</div>
|
||
</div>
|
||
<div class="controls" style="margin-top:16px">
|
||
<label>提示词:</label>
|
||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜">
|
||
</div>
|
||
<div style="text-align:center; margin-top:16px">
|
||
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right: Result -->
|
||
<div class="panel">
|
||
<h2>2. 对比结果</h2>
|
||
<div id="resultArea">
|
||
<div class="placeholder">生成结果将显示在这里</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const imageInput = document.getElementById('imageInput');
|
||
const maskInput = document.getElementById('maskInput');
|
||
const imageWrapper = document.getElementById('imageWrapper');
|
||
const placeholder = document.getElementById('placeholder');
|
||
const promptInput = document.getElementById('promptInput');
|
||
const generateBtn = document.getElementById('generateBtn');
|
||
const resultArea = document.getElementById('resultArea');
|
||
|
||
let originalImage = null;
|
||
let maskImage = null;
|
||
|
||
imageInput.addEventListener('change', (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
originalImage = { img: img, file: file };
|
||
updatePreview();
|
||
checkReady();
|
||
};
|
||
img.src = ev.target.result;
|
||
};
|
||
reader.readAsDataURL(file);
|
||
});
|
||
|
||
maskInput.addEventListener('change', (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
maskImage = { img: img, file: file };
|
||
updatePreview();
|
||
checkReady();
|
||
};
|
||
img.src = ev.target.result;
|
||
};
|
||
reader.readAsDataURL(file);
|
||
});
|
||
|
||
function updatePreview() {
|
||
placeholder.style.display = 'none';
|
||
let html = '';
|
||
if (originalImage) {
|
||
html += `<div class="image-item"><h4>人物图片</h4><img src="${originalImage.img.src}" alt="原图"></div>`;
|
||
}
|
||
if (maskImage) {
|
||
html += `<div class="image-item"><h4>遮罩图片</h4><img src="${maskImage.img.src}" alt="遮罩"></div>`;
|
||
}
|
||
imageWrapper.innerHTML = html;
|
||
}
|
||
|
||
function checkReady() {
|
||
generateBtn.disabled = !(originalImage && maskImage);
|
||
}
|
||
|
||
generateBtn.addEventListener('click', async () => {
|
||
if (!originalImage || !maskImage) return;
|
||
|
||
generateBtn.disabled = true;
|
||
generateBtn.textContent = '⏳ 生成中...';
|
||
|
||
const startTime = performance.now();
|
||
resultArea.innerHTML = '<div class="loading"><div class="spinner"></div><br>正在调用 ComfyUI 生成,请耐心等待...<div id="liveTimer" style="margin-top:8px;font-size:15px;color:#aaa">已用时 0.0s</div></div>';
|
||
const liveTimer = document.getElementById('liveTimer');
|
||
const timerId = setInterval(() => {
|
||
if (liveTimer) liveTimer.textContent = '已用时 ' + ((performance.now() - startTime) / 1000).toFixed(1) + 's';
|
||
}, 100);
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('image', originalImage.file, 'original.' + originalImage.file.name.split('.').pop());
|
||
formData.append('mask', maskImage.file, 'mask.' + maskImage.file.name.split('.').pop());
|
||
formData.append('prompt', promptInput.value);
|
||
|
||
const resp = await fetch('/api/generate', { method: 'POST', body: formData });
|
||
if (!resp.ok) {
|
||
const err = await resp.json();
|
||
throw new Error(err.error || 'Generation failed');
|
||
}
|
||
|
||
const resultBlob = await resp.blob();
|
||
const resultUrl = URL.createObjectURL(resultBlob);
|
||
const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
|
||
// 服务端纯推理耗时(若返回该响应头)
|
||
const serverTime = resp.headers.get('X-Generate-Time');
|
||
const serverInfo = serverTime ? `,服务端推理 ${parseFloat(serverTime).toFixed(1)}s` : '';
|
||
|
||
resultArea.innerHTML = `
|
||
<div style="text-align:center;margin-bottom:12px;color:#4ade80;font-size:16px;font-weight:600">
|
||
⏱️ 本次重绘耗时 ${elapsed}s${serverInfo}
|
||
</div>
|
||
<div class="result-area">
|
||
<div class="result-item">
|
||
<h3>原图</h3>
|
||
<img src="${originalImage.img.src}" alt="原图">
|
||
</div>
|
||
<div class="result-item">
|
||
<h3>生成结果</h3>
|
||
<img src="${resultUrl}" alt="生成结果">
|
||
</div>
|
||
</div>
|
||
`;
|
||
} catch (err) {
|
||
const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
|
||
resultArea.innerHTML = `<div class="error">❌ ${err.message}<br><span style="color:#aaa;font-size:13px">(耗时 ${elapsed}s)</span></div>`;
|
||
} finally {
|
||
clearInterval(timerId);
|
||
generateBtn.disabled = false;
|
||
generateBtn.textContent = '🚀 生成';
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|