452 lines
21 KiB
HTML
452 lines
21 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>接口 22 · 生发测试</title>
|
||
<style>
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; background: #f3f5f8; color: #172334; font: 15px/1.5 system-ui, sans-serif; }
|
||
main { max-width: 1100px; margin: 32px auto; padding: 0 16px; }
|
||
h1 { margin: 0 0 4px; font-size: 28px; }
|
||
h2 { margin: 0 0 12px; font-size: 18px; }
|
||
.muted, figcaption { color: #526075; }
|
||
.card { background: white; border: 1px solid #dce3eb; border-radius: 12px; padding: 18px; margin: 16px 0; }
|
||
.fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; }
|
||
.field { display: grid; gap: 5px; align-content: start; }
|
||
input, select, button { font: inherit; }
|
||
.field input, select { min-width: 0; width: 100%; padding: 6px 8px; }
|
||
fieldset { margin: 16px 0; border: 1px solid #dce3eb; border-radius: 8px; }
|
||
#sourceFields { margin-top: 0; padding: 0; border: 0; min-width: 0; }
|
||
.choices { display: flex; flex-wrap: wrap; gap: 8px 18px; }
|
||
.choices label { white-space: nowrap; }
|
||
button { background: #1757ab; color: white; border: 0; border-radius: 8px; padding: 8px 16px; cursor: pointer; }
|
||
button:disabled { opacity: .6; cursor: wait; }
|
||
.status { margin-bottom: 0; }
|
||
.status[data-error="true"] { color: #a02020; }
|
||
.images { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 12px; }
|
||
.images figure { max-width: 380px; }
|
||
.result-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 122px), 1fr)); gap: 10px; }
|
||
.result-tile { min-width: 0; overflow: hidden; background: white; border: 1px solid #dce3eb; border-radius: 8px; }
|
||
.result-tile img { display: block; width: 100%; height: auto; }
|
||
.result-tile figcaption { display: grid; gap: 2px; margin: 0; padding: 6px 8px; font-size: 12px; }
|
||
.result-tile strong { color: #172334; }
|
||
.preview-editor { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; margin: 16px 0; }
|
||
.preview-editor:has(#previews > figure:only-child) { grid-template-columns: minmax(0, 380px) auto; justify-content: start; }
|
||
.height-controls { display: grid; gap: 6px; padding-top: 27px; }
|
||
.height-scale { display: flex; gap: 6px; height: clamp(170px, 42vw, 300px); }
|
||
#height { writing-mode: vertical-lr; direction: rtl; width: 24px; height: 100%; margin: 0; }
|
||
.height-presets { display: flex; flex-direction: column; justify-content: space-between; }
|
||
.height-presets label { display: flex; align-items: center; gap: 3px; cursor: pointer; white-space: nowrap; }
|
||
figure { margin: 0; min-width: 0; }
|
||
figcaption { margin-bottom: 6px; }
|
||
.stack { position: relative; overflow: hidden; border-radius: 6px; }
|
||
.stack img, .stack canvas { width: 100%; height: auto; display: block; border-radius: 6px; }
|
||
.preview-layer, .preview-clip, .stack .overlay, .crop-border { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||
.preview-layer { transform: translateY(calc(-1 * var(--height-adjustment) * var(--max-shift))); }
|
||
.crop-border polygon { fill: none; stroke: #e33399; stroke-width: 2; stroke-dasharray: 6 4; vector-effect: non-scaling-stroke; }
|
||
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||
@media (max-width: 560px) { .images { grid-template-columns: 1fr; } }
|
||
</style>
|
||
<script src="/static/img_downscale.js?v=2" defer></script>
|
||
<script src="/static/hairline_reference_box.js?v=2" defer></script>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<h1>接口 22 生发测试</h1>
|
||
<div class="muted"><code>POST /api/v1/hair/grow-22</code></div>
|
||
<form id="generateRequest" class="card">
|
||
<fieldset id="sourceFields" aria-label="照片与性别">
|
||
<div class="fields">
|
||
<label class="field">照片(JPG / PNG)
|
||
<input type="file" name="image_file" accept="image/jpeg,image/png,.jpg,.jpeg,.png" required>
|
||
</label>
|
||
<label class="field">性别
|
||
<select name="gender"><option value="female">女性</option><option value="male">男性</option></select>
|
||
</label>
|
||
</div>
|
||
</fieldset>
|
||
<fieldset id="styleFields">
|
||
<legend>发际线形状(可多选)</legend>
|
||
<div id="styles" class="choices"></div>
|
||
<p id="generationPreviewStatus" class="status" role="status"></p>
|
||
</fieldset>
|
||
<p id="firstRunHint" class="muted">首次按默认高度 0 生图,出图后可预览和调整发际线高度。</p>
|
||
<div id="heightEditor" hidden>
|
||
<p class="muted">勾选右侧档位后按所选高度生图。未勾选时,使用滑块当前高度。</p>
|
||
<div class="preview-editor">
|
||
<div id="previews" class="images" aria-label="52 号裁切发际线预览"></div>
|
||
<div class="height-controls">
|
||
<label for="height">高度 <output id="heightValue" for="height">0</output></label>
|
||
<div class="height-scale">
|
||
<input type="range" id="height" name="hairline_height" min="-1" max="1" step="0.1" value="0">
|
||
<div class="height-presets" role="group" aria-label="预设高度(可多选)">
|
||
<label><input type="checkbox" name="height_preset" value="1"> +1</label>
|
||
<label><input type="checkbox" name="height_preset" value="0.5"> +0.5</label>
|
||
<label><input type="checkbox" name="height_preset" value="0"> 0</label>
|
||
<label><input type="checkbox" name="height_preset" value="-0.5"> −0.5</label>
|
||
<label><input type="checkbox" name="height_preset" value="-1"> −1</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<fieldset>
|
||
<legend>生发密度(至少选择一档)</legend>
|
||
<div class="choices">
|
||
<label><input type="checkbox" name="hair_density" value="sparse"> 稀疏</label>
|
||
<label><input type="checkbox" name="hair_density" value="normal" checked> 正常</label>
|
||
<label><input type="checkbox" name="hair_density" value="dense"> 浓密</label>
|
||
</div>
|
||
</fieldset>
|
||
<button id="submit" type="submit">生成生发效果</button>
|
||
<p id="status" class="status" role="status"></p>
|
||
</form>
|
||
<section id="results" class="result-grid" aria-label="生发结果"></section>
|
||
<details class="card" id="response" hidden>
|
||
<summary>响应 JSON(省略图片 Base64)</summary>
|
||
<pre id="json"></pre>
|
||
</details>
|
||
</main>
|
||
<script>
|
||
const generateForm = document.getElementById('generateRequest');
|
||
const sourceFields = document.getElementById('sourceFields');
|
||
const firstRunHint = document.getElementById('firstRunHint');
|
||
const heightEditor = document.getElementById('heightEditor');
|
||
const previews = document.getElementById('previews');
|
||
const height = document.getElementById('height');
|
||
const heightPresets = generateForm.querySelectorAll('[name="height_preset"]');
|
||
const styles = document.getElementById('styles');
|
||
const styleFields = document.getElementById('styleFields');
|
||
const generationPreviewStatus = document.getElementById('generationPreviewStatus');
|
||
const statusBar = document.getElementById('status');
|
||
const results = document.getElementById('results');
|
||
const response = document.getElementById('response');
|
||
const submit = document.getElementById('submit');
|
||
const densityLabels = { sparse: '稀疏', normal: '正常', dense: '浓密' };
|
||
const styleNames = {
|
||
female: ['椭圆', '花瓣', '心形', '直线', '波浪', '大花瓣', '古典花瓣'],
|
||
male: ['椭圆', '倒弧', 'M 形', '直线', '心形', '柔和花瓣', '波浪'],
|
||
};
|
||
let prepared;
|
||
let previewRevision = 0;
|
||
|
||
function renderStyleChoices(container, gender, selected) {
|
||
container.replaceChildren(...styleNames[gender].map((name, index) => {
|
||
const label = document.createElement('label');
|
||
const checkbox = document.createElement('input');
|
||
checkbox.type = 'checkbox';
|
||
checkbox.name = 'hair_style';
|
||
checkbox.value = index + 1;
|
||
checkbox.checked = selected.includes(checkbox.value);
|
||
label.append(checkbox, ' ' + (index + 1) + '. ' + name);
|
||
return label;
|
||
}));
|
||
}
|
||
|
||
function imageSource(url, base64, mime) {
|
||
if (url) return url;
|
||
if (!base64) return '';
|
||
return base64.startsWith('data:') ? base64 : 'data:' + mime + ';base64,' + base64;
|
||
}
|
||
|
||
function imagePanel(title, src, preview) {
|
||
const figure = document.createElement('figure');
|
||
const caption = document.createElement('figcaption');
|
||
caption.textContent = title;
|
||
figure.append(caption);
|
||
if (!src) {
|
||
figure.append(preview?.error || '未返回图片');
|
||
return figure;
|
||
}
|
||
const stack = document.createElement('div');
|
||
stack.className = 'stack';
|
||
const image = new Image();
|
||
image.src = src;
|
||
image.alt = title;
|
||
stack.append(image);
|
||
if (preview) {
|
||
const movingLayer = document.createElement('div');
|
||
movingLayer.className = 'preview-layer';
|
||
movingLayer.style.setProperty('--max-shift', preview.maxShift + '%');
|
||
const clip = document.createElement('div');
|
||
clip.className = 'preview-clip';
|
||
const polygonPoints = preview.corners.map(([x, y]) =>
|
||
(100 * x / preview.width) + '% ' + (100 * y / preview.height) + '%');
|
||
clip.style.clipPath = 'polygon(' + polygonPoints.join(', ') + ')';
|
||
const layer = new Image();
|
||
layer.src = preview.overlay;
|
||
layer.alt = '发际线';
|
||
layer.className = 'overlay';
|
||
clip.append(layer);
|
||
movingLayer.append(clip);
|
||
const ns = 'http://www.w3.org/2000/svg';
|
||
const border = document.createElementNS(ns, 'svg');
|
||
border.classList.add('crop-border');
|
||
border.setAttribute('viewBox', '0 0 ' + preview.width + ' ' + preview.height);
|
||
border.setAttribute('aria-label', '发际线裁切边界');
|
||
const polygon = document.createElementNS(ns, 'polygon');
|
||
polygon.setAttribute('points', preview.corners.map(point => point.join(',')).join(' '));
|
||
border.append(polygon);
|
||
movingLayer.append(border);
|
||
stack.append(movingLayer);
|
||
}
|
||
figure.append(stack);
|
||
return figure;
|
||
}
|
||
|
||
async function loadCroppedPreviews(data, selected) {
|
||
const formData = new FormData();
|
||
for (const [key, value] of data) formData.set(key, value);
|
||
formData.set('hair_style', selected.join(','));
|
||
formData.set('normalize_image', 'true');
|
||
formData.set('generate_grow_image', 'false');
|
||
const reply = await fetch('/api/v1/hairline/generate-52', { method: 'POST', body: formData });
|
||
const body = await reply.json();
|
||
if (!reply.ok || body.code !== 0) {
|
||
throw new Error(body.message || 'HTTP ' + reply.status);
|
||
}
|
||
const preview = body.data;
|
||
const base = imageSource(preview.input_image_url, preview.input_image_base64, 'image/jpeg');
|
||
const { width, height } = preview;
|
||
const { corners, landmarks } = window.hairlineReferenceBox(preview.guide_landmarks);
|
||
// Convert the interface 22 scale (10% of brow-to-chin distance) to a percentage of the image height.
|
||
const maxShift = 100 * 0.10 * Math.abs(landmarks[152][1] - landmarks[9][1]) / height;
|
||
const items = preview.hairline_images.map(item => {
|
||
const overlay = imageSource(item.image_middle_url, item.image_middle_base64, 'image/png');
|
||
return { order: item.order, ...((base && overlay)
|
||
? { base, corners, width, height, maxShift, overlay }
|
||
: { error: '接口 52 未返回完整预览图片' }) };
|
||
});
|
||
return { items, framing: preview.framing };
|
||
}
|
||
|
||
function makePreviewPanels(items, gender) {
|
||
const names = styleNames[gender];
|
||
return new Map(items.filter(item => item.base).map(item => [
|
||
String(item.order), imagePanel('#' + item.order + ' · ' + names[item.order - 1], item.base, item),
|
||
]));
|
||
}
|
||
|
||
async function replacePreviewBases(images, panels, framing, fallback = false) {
|
||
const byOrder = new Map(images.map(item => [String(item.order), item]));
|
||
const targets = [...panels].flatMap(([order, panel]) => {
|
||
const image = byOrder.get(order) || (fallback ? images[0] : undefined);
|
||
return image ? [[image, panel]] : [];
|
||
});
|
||
if (!targets.length) return 0;
|
||
const { source_to_processed: matrix, source_size: source, output_size: output } = framing || {};
|
||
if (!matrix || !source || !output) return targets.length;
|
||
const [[a, c, e], [b, d, f]] = matrix;
|
||
const updated = await Promise.all(targets.map(async ([item, panel]) => {
|
||
const base = panel.querySelector('.stack > img');
|
||
if (!base) return false;
|
||
try {
|
||
const generated = new Image();
|
||
generated.src = imageSource(item.grown_image_url, item.grown_image_base64, 'image/jpeg');
|
||
await generated.decode();
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = output.width;
|
||
canvas.height = output.height;
|
||
canvas.setAttribute('role', 'img');
|
||
canvas.setAttribute('aria-label', base.alt + ' · 默认高度生发底图');
|
||
const ctx = canvas.getContext('2d', { alpha: false });
|
||
ctx.setTransform(a, b, c, d, e, f);
|
||
ctx.drawImage(generated, 0, 0, source.width, source.height);
|
||
base.replaceWith(canvas);
|
||
return true;
|
||
} catch (error) {
|
||
return false;
|
||
}
|
||
}));
|
||
return updated.filter(ok => !ok).length;
|
||
}
|
||
|
||
function renderResult(item, submittedHeight, gender) {
|
||
const tile = document.createElement('figure');
|
||
tile.className = 'result-tile';
|
||
const image = new Image();
|
||
image.loading = 'lazy';
|
||
image.src = imageSource(item.grown_image_url, item.grown_image_base64, 'image/jpeg');
|
||
const name = styleNames[gender][item.order - 1] || item.hairline_type;
|
||
const style = '#' + item.order + ' ' + name + ' · ' + (gender === 'male' ? '男性' : '女性');
|
||
const details = (densityLabels[item.hair_density] || item.hair_density) + ' · 高度 ' + submittedHeight;
|
||
image.alt = style + ',' + details;
|
||
const caption = document.createElement('figcaption');
|
||
const title = document.createElement('strong');
|
||
title.textContent = style;
|
||
const parameters = document.createElement('span');
|
||
parameters.textContent = details;
|
||
caption.append(title, parameters);
|
||
tile.append(image, caption);
|
||
return tile;
|
||
}
|
||
|
||
async function requestGrowth(data) {
|
||
const reply = await fetch('/api/v1/hair/grow-22', { method: 'POST', body: data });
|
||
const body = await reply.json();
|
||
if (!reply.ok || body.code !== 0) {
|
||
throw new Error(body.message || 'HTTP ' + reply.status);
|
||
}
|
||
return body;
|
||
}
|
||
|
||
function updatePreviewHeight() {
|
||
document.getElementById('heightValue').value = height.value;
|
||
previews.style.setProperty('--height-adjustment', height.value);
|
||
}
|
||
|
||
height.addEventListener('input', updatePreviewHeight);
|
||
heightPresets.forEach(preset => preset.addEventListener('change', () => {
|
||
if (preset.checked) {
|
||
height.value = preset.value;
|
||
updatePreviewHeight();
|
||
}
|
||
}));
|
||
updatePreviewHeight();
|
||
function renderSourceStyles() {
|
||
renderStyleChoices(styles, generateForm.elements.gender.value, ['1']);
|
||
}
|
||
|
||
generateForm.elements.gender.addEventListener('change', renderSourceStyles);
|
||
renderSourceStyles();
|
||
|
||
sourceFields.addEventListener('change', () => {
|
||
prepared = undefined;
|
||
heightEditor.hidden = true;
|
||
firstRunHint.hidden = false;
|
||
previews.replaceChildren();
|
||
results.replaceChildren();
|
||
response.hidden = true;
|
||
statusBar.textContent = '';
|
||
generationPreviewStatus.textContent = '';
|
||
submit.disabled = false;
|
||
height.value = '0';
|
||
heightPresets.forEach(preset => { preset.checked = false; });
|
||
updatePreviewHeight();
|
||
});
|
||
|
||
async function refreshStylePreviews(selected) {
|
||
const current = prepared;
|
||
if (!current) return;
|
||
const revision = ++previewRevision;
|
||
const showSelected = () => previews.replaceChildren(...selected.flatMap(order =>
|
||
current.panels.has(order) ? [current.panels.get(order)] : []));
|
||
showSelected();
|
||
generationPreviewStatus.dataset.error = 'false';
|
||
generationPreviewStatus.textContent = selected.length ? '' : '请至少选择一种发际线形状。';
|
||
const missing = selected.filter(order => !current.panels.has(order));
|
||
submit.disabled = !selected.length || !!missing.length;
|
||
if (!missing.length) return;
|
||
generationPreviewStatus.textContent = '正在更新发际线预览…';
|
||
try {
|
||
const { items, framing } = await loadCroppedPreviews(current.data, missing);
|
||
const panels = makePreviewPanels(items, current.data.get('gender'));
|
||
const unmatched = await replacePreviewBases(current.defaultImages, panels, framing, true);
|
||
if (current !== prepared || revision !== previewRevision) return;
|
||
for (const [order, panel] of panels) current.panels.set(order, panel);
|
||
showSelected();
|
||
if (missing.some(order => !panels.has(order))) {
|
||
throw new Error('接口 52 未返回全部所选形状。');
|
||
}
|
||
generationPreviewStatus.textContent = unmatched ? '部分生成图无法对齐,已显示对应的裁切原照。' : '';
|
||
} catch (error) {
|
||
if (current !== prepared || revision !== previewRevision) return;
|
||
generationPreviewStatus.dataset.error = 'true';
|
||
generationPreviewStatus.textContent = '更新发际线预览失败:' + error.message;
|
||
} finally {
|
||
if (current === prepared && revision === previewRevision) submit.disabled = false;
|
||
}
|
||
}
|
||
|
||
styles.addEventListener('change', () => {
|
||
refreshStylePreviews(new FormData(generateForm).getAll('hair_style'));
|
||
});
|
||
|
||
generateForm.addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
if (submit.disabled) return;
|
||
const data = new FormData(generateForm);
|
||
const selectedStyles = data.getAll('hair_style');
|
||
if (!selectedStyles.length) {
|
||
statusBar.dataset.error = 'true';
|
||
statusBar.textContent = '请至少选择一种发际线形状。';
|
||
return;
|
||
}
|
||
const densities = data.getAll('hair_density');
|
||
if (!densities.length) {
|
||
statusBar.dataset.error = 'true';
|
||
statusBar.textContent = '请至少选择一档生发密度。';
|
||
return;
|
||
}
|
||
const selectedHeights = data.getAll('height_preset');
|
||
const firstRun = !prepared;
|
||
const submittedHeights = firstRun ? ['0'] : (selectedHeights.length ? selectedHeights : [data.get('hairline_height')]);
|
||
data.delete('height_preset');
|
||
data.set('hair_style', selectedStyles.join(','));
|
||
sourceFields.disabled = styleFields.disabled = submit.disabled = true;
|
||
statusBar.dataset.error = 'false';
|
||
results.replaceChildren();
|
||
response.hidden = true;
|
||
const reports = [];
|
||
const failedHeights = [];
|
||
const defaultImages = new Map();
|
||
const planned = submittedHeights.length * densities.length * selectedStyles.length;
|
||
let generated = 0;
|
||
const start = performance.now();
|
||
try {
|
||
statusBar.textContent = '正在准备照片…';
|
||
const file = firstRun
|
||
? await window.downscaleImageFile(data.get('image_file'))
|
||
: prepared.data.get('image_file');
|
||
data.set('image_file', file);
|
||
for (const [index, submittedHeight] of submittedHeights.entries()) {
|
||
statusBar.textContent = '正在生成 ' + (index + 1) + '/' + submittedHeights.length + ' 档 · 高度 ' + submittedHeight + '…';
|
||
// The slider is positive up, while the API uses positive image Y (down).
|
||
data.set('hairline_height', String(-Number(submittedHeight)));
|
||
try {
|
||
const body = await requestGrowth(data);
|
||
const images = body.data.results.filter(item => item.grown_image_url || item.grown_image_base64);
|
||
results.append(...images.map(item => renderResult(item, submittedHeight, data.get('gender'))));
|
||
generated += images.length;
|
||
if (firstRun) {
|
||
for (const item of images) {
|
||
if (!defaultImages.has(item.order) || item.hair_density === 'normal') {
|
||
const { order, grown_image_url, grown_image_base64 } = item;
|
||
defaultImages.set(order, { order, grown_image_url, grown_image_base64 });
|
||
}
|
||
}
|
||
}
|
||
reports.push(JSON.stringify({ height: Number(submittedHeight), response: body }, (key, value) =>
|
||
key.endsWith('_base64') && typeof value === 'string' ? '[图片已省略]' : value, 2));
|
||
} catch (error) {
|
||
failedHeights.push(submittedHeight);
|
||
reports.push(JSON.stringify({ height: Number(submittedHeight), error: error.message }, null, 2));
|
||
}
|
||
}
|
||
document.getElementById('json').textContent = '[\n' + reports.join(',\n') + '\n]';
|
||
response.hidden = false;
|
||
const seconds = ((performance.now() - start) / 1000).toFixed(1);
|
||
statusBar.dataset.error = String(generated !== planned);
|
||
statusBar.textContent = '生发成功 ' + generated + '/' + planned + ' 张 · ' + seconds + ' 秒'
|
||
+ (failedHeights.length ? ' · 请求失败的高度 ' + failedHeights.join('、') : '');
|
||
if (firstRun && generated) {
|
||
const source = new FormData();
|
||
source.set('image_file', file);
|
||
source.set('gender', data.get('gender'));
|
||
prepared = { data: source, panels: new Map(), defaultImages: [...defaultImages.values()] };
|
||
heightEditor.hidden = false;
|
||
firstRunHint.hidden = true;
|
||
await refreshStylePreviews(selectedStyles);
|
||
}
|
||
} catch (error) {
|
||
statusBar.dataset.error = 'true';
|
||
statusBar.textContent = '生发失败:' + error.message;
|
||
} finally {
|
||
sourceFields.disabled = styleFields.disabled = submit.disabled = false;
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|