Files
hair/static/test_interface2.html
T
xsl ff4019c570 chore: 默认模型固定为 9B-Q4_K_M,测试页移除模型/分辨率选择器
测试结论: 9B-Q4_K_M 分辨率896 为最佳性价比组合。
- 三个工作流(0716add-hair-api/add_hair/hair_repaint)默认模型改为
  flux-2-klein-9b-Q4_K_M.gguf (UnetLoaderGGUF节点)
- test_interface2.html 移除 Flux模型/压图长边 下拉选择器,
  接口调用不再传 flux_model/redraw_max_side,统一用工作流默认(Q4/896)
2026-07-25 16:25:44 +08:00

278 lines
14 KiB
HTML
Raw 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>接口2 — C端生发 测试页</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: 1400px; 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-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.file-input { flex: 1; min-width: 200px; }
.file-input input[type=file] { width: 100%; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
.form-group { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.form-group label { font-size: 13px; font-weight: 600; color: #374151; }
.form-group select { padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; }
.hair-checkboxes { display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: center; }
.hair-checkboxes label { font-size: 13px; font-weight: 500; color: #374151; cursor: pointer; display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; }
.hair-checkboxes label input[type=checkbox] { cursor: pointer; }
.checkbox-actions { display: flex; gap: 6px; margin-left: 4px; }
.checkbox-actions button { font-size: 11px; padding: 2px 8px; border-radius: 4px; border: 1px solid #d1d5db; background: #f9fafb; cursor: pointer; }
.checkbox-actions button:hover { background: #e5e7eb; }
.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.8; min-width: 0; }
.col-side { flex: 1; min-width: 0; }
/* 方案列表 — 每个方案一个横向卡片 */
.scheme-card { background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 14px; overflow: hidden; }
.scheme-header { padding: 10px 16px; background: #fafafa; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 700; }
.scheme-header .badge { background: #2563eb; color: #fff; padding: 2px 10px; border-radius: 10px; font-size: 11px; }
.scheme-header .tag { font-size: 11px; color: #6b7280; }
.scheme-imgs { display: flex; gap: 12px; padding: 12px 16px; }
.scheme-imgs .img-slot { flex: 1; min-width: 0; text-align: center; }
.scheme-imgs .img-slot .label { font-size: 11px; font-weight: 600; color: #6b7280; margin-bottom: 6px; text-transform: uppercase; letter-spacing: .3px; }
.scheme-imgs .img-slot .label .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
.scheme-imgs .img-slot .label .dot.preview { background: #f59e0b; }
.scheme-imgs .img-slot .label .dot.grown { background: #059669; }
.scheme-imgs .img-slot .thumb { background: #222; border-radius: 6px; overflow: hidden; line-height: 0; }
.scheme-imgs .img-slot .thumb img { width: 100%; max-height: 220px; object-fit: contain; display: block; }
.scheme-imgs .img-slot .na { color: #d1d5db; font-size: 12px; padding: 30px 0; background: #f9fafb; border-radius: 6px; }
/* 原图 + 透明 PNG 叠加显示 */
.img-stack { position: relative; display: inline-block; line-height: 0; }
.img-stack .layer-base { display: block; width: 100%; max-height: 220px; object-fit: contain; }
.img-stack .layer-anno { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; }
.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; }
</style>
<script src="/static/img_downscale.js?v=2"></script>
</head>
<body>
<div class="container">
<h1>💇 接口2 — C端生发 测试</h1>
<p class="subtitle">POST /api/v1/hair/grow &nbsp;|&nbsp; 正面照 + 性别 + 发型多选 → 所选发型的预览图与生发图</p>
<div class="card">
<div class="card-body">
<div class="upload-row">
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
<div class="form-group">
<label>性别</label>
<select id="gender" onchange="onGenderChange()"><option value="female" selected>👩 Female</option><option value="male">👨 Male</option></select>
</div>
<div class="form-group" style="align-items:flex-start">
<label style="padding-top:3px">发型</label>
<div>
<div class="hair-checkboxes" id="hairStyleGroup"></div>
<div class="checkbox-actions" style="margin-top:4px">
<button type="button" onclick="selectAllHair(true)">全选</button>
<button type="button" onclick="selectAllHair(false)">全不选</button>
</div>
</div>
</div>
<div class="form-group">
<label>inpaint 遮罩</label>
<select id="useMask">
<option value="true" selected>✅ 启用(默认)</option>
<option value="false">⬜ 关闭 — 空遮罩,不烧模板线</option>
</select>
</div>
<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; 生发图生成较慢(数十秒~数分钟),请耐心等待</div>
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
</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>🗂️ 生发结果</span></div>
<div class="card-body" id="schemesContainer"></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;
let _origUrl = '';
const TYPE_LABELS = {
ellipse:'椭圆形', flower:'花瓣形', heart:'心形', straight:'直线形', wave:'波浪形', m:'M形', inverse_arc:'倒弧形'
};
function $(id) { return document.getElementById(id); }
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
async function submitTest() {
let f = $('imageFile').files[0];
if (!f) { setStatus('请选择图片', 'error'); return; }
f = await window.downscaleImageFile(f);
_origUrl = URL.createObjectURL(f);
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ 请求中...';
setStatus('⏳ 请求中(生发图生成较慢,请耐心)...', 'info'); $('resultsArea').classList.add('hidden');
const checked = [...document.querySelectorAll('#hairStyleGroup input:checked')].map(cb => cb.value);
if (checked.length === 0) { setStatus('请至少勾选一个发型', 'error'); $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 提交'; return; }
const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value);
fd.append('hair_style', checked.join(','));
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', { 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 results = json.data.results || [];
setStatus('✅ ' + results.length + ' 个方案 (' + _elapsed + 's)', 'success');
renderSchemes(results);
} else {
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
$('schemesContainer').innerHTML = '<div style="color:#9ca3af">无结果</div>';
}
} 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 renderSchemes(results) {
let html = '';
results.forEach((r, i) => {
const lb = TYPE_LABELS[r.hairline_type] || r.hairline_type;
// 兼容网关 *_url 与 worker *_base64(原始或 data URI
const overlaySrc = resolveImgSrc(r.image_url, r.image_base64, 'image/png');
const grownSrc = resolveImgSrc(r.grown_image_url, r.grown_image_base64, 'image/jpeg');
// 原图
const origSlot = '<div class="img-slot">'+
'<div class="label">📷 原图</div>'+
'<div class="thumb"><img src="'+_origUrl+'" alt="原图"></div></div>';
// 发际线叠加(原图 + 透明 PNG 叠加显示)
const overlaySlot = '<div class="img-slot">'+
'<div class="label"><span class="dot preview"></span>原图+发际线叠加</div>'+
'<div class="thumb"><div class="img-stack">'+
'<img class="layer-base" src="'+_origUrl+'" alt="原图">'+
'<img class="layer-anno" src="'+overlaySrc+'" alt="叠加">'+
'</div></div></div>';
// 生发效果
let grownSlot;
if (grownSrc) {
grownSlot = '<div class="img-slot">'+
'<div class="label"><span class="dot grown"></span>生发效果</div>'+
'<div class="thumb"><img src="'+grownSrc+'" alt="生发"></div></div>';
} else {
grownSlot = '<div class="img-slot">'+
'<div class="label"><span class="dot grown"></span>生发效果</div>'+
'<div class="na">⚠ 未返回<br><span style="font-size:10px;color:#9ca3af">生发失败(female=换发型/重绘,male=ComfyUI</span></div></div>';
}
html += '<div class="scheme-card">'+
'<div class="scheme-header"><span class="badge">#'+r.order+'</span> '+lb+
'<span class="tag">hairline_type: '+r.hairline_type+'</span></div>'+
'<div class="scheme-imgs">'+origSlot+overlaySlot+grownSlot+'</div>'+
'</div>';
});
$('schemesContainer').innerHTML = html;
}
function clearResults() {
_origUrl='';
$('resultsArea').classList.add('hidden'); $('statusBar').className='status hidden';
$('imageFile').value=''; $('jsonContent').textContent=''; $('schemesContainer').innerHTML='';
}
function copyJson() {
navigator.clipboard.writeText($('jsonContent').textContent).then(() => {
const b = event.target, o = b.textContent; b.textContent = '✅ 已复制'; setTimeout(() => b.textContent = o, 1500);
});
}
const HAIR_STYLES = {
female: [
{ value:'1', label:'ellipse 椭圆形' },
{ value:'2', label:'flower 花瓣形' },
{ value:'3', label:'heart 心形' },
{ value:'4', label:'straight 直线形' },
{ value:'5', label:'wave 波浪形' },
],
male: [
{ value:'1', label:'ellipse 椭圆形' },
{ value:'2', label:'inverse_arc 倒弧形' },
{ value:'3', label:'m M形' },
{ value:'4', label:'straight 直线形' },
]
};
function onGenderChange() {
const g = $('gender').value;
const div = $('hairStyleGroup');
div.innerHTML = '';
HAIR_STYLES[g].forEach(s => {
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.value = s.value; cb.id = 'hs_' + s.value;
const lb = document.createElement('label');
lb.htmlFor = cb.id; lb.appendChild(cb);
lb.appendChild(document.createTextNode(' ' + s.label));
div.appendChild(lb);
});
}
function selectAllHair(select) {
document.querySelectorAll('#hairStyleGroup input[type=checkbox]').forEach(cb => { cb.checked = select; });
}
document.addEventListener('DOMContentLoaded', () => {
onGenderChange(); // 初始填充发型列表
const dz = $('imageFile');
dz.addEventListener('dragover', e => { e.preventDefault(); dz.style.borderColor='#2563eb'; });
dz.addEventListener('dragleave', () => dz.style.borderColor='#ddd');
dz.addEventListener('drop', e => { e.preventDefault(); dz.style.borderColor='#ddd'; if(e.dataTransfer.files.length){ dz.files=e.dataTransfer.files; setStatus('已选择: '+e.dataTransfer.files[0].name, 'info'); } });
dz.addEventListener('change', () => { if(dz.files.length) setStatus('已选择: '+dz.files[0].name, 'info'); });
});
</script>
</body>
</html>