311 lines
9.2 KiB
HTML
311 lines
9.2 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||
<meta name="mobile-web-app-capable" content="yes" />
|
||
<meta name="theme-color" content="#0f1115" />
|
||
<title>Face SDK Web · 测试</title>
|
||
<style>
|
||
:root {
|
||
--bg: #0f1115;
|
||
--card: #1a1d24;
|
||
--border: #2a2f3a;
|
||
--text: #e6e8eb;
|
||
--muted: #8a92a3;
|
||
--accent: #4f8cff;
|
||
--accent-hover: #3d78e8;
|
||
--error: #ff6b6b;
|
||
--ok: #4fd18b;
|
||
}
|
||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||
html, body { overflow-x: hidden; }
|
||
body {
|
||
margin: 0;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||
padding: 24px;
|
||
padding-left: max(24px, env(safe-area-inset-left));
|
||
padding-right: max(24px, env(safe-area-inset-right));
|
||
padding-bottom: max(24px, env(safe-area-inset-bottom));
|
||
min-height: 100vh;
|
||
min-height: 100dvh;
|
||
-webkit-text-size-adjust: 100%;
|
||
}
|
||
h1 { margin: 0 0 8px; font-size: 22px; font-weight: 600; }
|
||
.subtitle { color: var(--muted); margin-bottom: 24px; font-size: 13px; }
|
||
.card {
|
||
background: var(--card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.row { display: flex; gap: 16px; flex-wrap: wrap; align-items: end; }
|
||
.field { display: flex; flex-direction: column; gap: 6px; flex: 1 1 200px; min-width: 0; }
|
||
label { font-size: 12px; color: var(--muted); }
|
||
/* font-size: 16px 防止 iOS Safari 聚焦时自动放大页面 */
|
||
input[type="file"], select {
|
||
background: #0f1115;
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 10px 12px;
|
||
font-size: 16px;
|
||
width: 100%;
|
||
min-height: 44px;
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
}
|
||
select {
|
||
/* 自定义下拉箭头(去掉默认样式后补回来) */
|
||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'><path fill='%238a92a3' d='M6 8L0 0h12z'/></svg>");
|
||
background-repeat: no-repeat;
|
||
background-position: right 12px center;
|
||
padding-right: 32px;
|
||
}
|
||
button {
|
||
background: var(--accent);
|
||
color: white;
|
||
border: 0;
|
||
border-radius: 6px;
|
||
padding: 12px 20px;
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: background 0.15s, transform 0.1s;
|
||
min-height: 44px;
|
||
touch-action: manipulation;
|
||
}
|
||
button:hover:not(:disabled) { background: var(--accent-hover); }
|
||
button:active:not(:disabled) { transform: scale(0.98); }
|
||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.status { margin-top: 12px; font-size: 13px; min-height: 18px; word-break: break-word; }
|
||
.status.error { color: var(--error); }
|
||
.status.ok { color: var(--ok); }
|
||
.preview {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 16px;
|
||
}
|
||
.preview-cell {
|
||
background: #0a0c10;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
min-height: 200px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.preview-cell h3 {
|
||
margin: 0 0 10px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--muted);
|
||
}
|
||
.preview-cell img {
|
||
max-width: 100%;
|
||
max-height: 70vh;
|
||
border-radius: 4px;
|
||
object-fit: contain;
|
||
align-self: center;
|
||
}
|
||
.placeholder {
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
text-align: center;
|
||
padding: 20px;
|
||
}
|
||
.download {
|
||
margin-top: 10px;
|
||
font-size: 13px;
|
||
}
|
||
.download a {
|
||
color: var(--accent);
|
||
text-decoration: none;
|
||
display: inline-block;
|
||
padding: 6px 0;
|
||
}
|
||
.download a:hover { text-decoration: underline; }
|
||
|
||
/* 平板 / 小桌面 */
|
||
@media (max-width: 720px) {
|
||
body { padding: 16px; padding-left: max(16px, env(safe-area-inset-left)); padding-right: max(16px, env(safe-area-inset-right)); }
|
||
h1 { font-size: 20px; }
|
||
.subtitle { margin-bottom: 16px; }
|
||
.card { padding: 16px; border-radius: 8px; margin-bottom: 16px; }
|
||
.row { gap: 12px; }
|
||
.field { flex: 1 1 100%; }
|
||
/* 移动端按钮整行,方便单手点 */
|
||
#submit-btn { width: 100%; flex: 1 1 100%; padding: 14px 20px; font-size: 16px; }
|
||
.preview { grid-template-columns: 1fr; gap: 12px; }
|
||
.preview-cell { padding: 10px; min-height: 160px; }
|
||
.preview-cell img { max-height: 60vh; max-height: 60dvh; }
|
||
}
|
||
|
||
/* 窄屏手机(375px 及以下) */
|
||
@media (max-width: 380px) {
|
||
body { padding: 12px; }
|
||
h1 { font-size: 18px; }
|
||
.card { padding: 14px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>Face SDK Web · 测试页</h1>
|
||
<div class="subtitle">上传一张人脸图片,选择效果 ID,查看叠加渲染结果。</div>
|
||
|
||
<div class="card">
|
||
<div class="row">
|
||
<div class="field">
|
||
<label for="image-input">图片</label>
|
||
<input type="file" id="image-input" accept="image/*" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="effect-select">效果 ID</label>
|
||
<select id="effect-select">
|
||
<option value="">加载中...</option>
|
||
</select>
|
||
</div>
|
||
<button id="submit-btn">渲染</button>
|
||
</div>
|
||
<div id="status" class="status"></div>
|
||
</div>
|
||
|
||
<div class="preview">
|
||
<div class="preview-cell">
|
||
<h3>原图</h3>
|
||
<div id="orig-wrap" class="placeholder">未选择图片</div>
|
||
</div>
|
||
<div class="preview-cell">
|
||
<h3>渲染结果</h3>
|
||
<div id="result-wrap" class="placeholder">等待渲染</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(() => {
|
||
const $ = (id) => document.getElementById(id);
|
||
const imageInput = $("image-input");
|
||
const effectSelect = $("effect-select");
|
||
const submitBtn = $("submit-btn");
|
||
const statusEl = $("status");
|
||
const origWrap = $("orig-wrap");
|
||
const resultWrap = $("result-wrap");
|
||
|
||
let lastResultUrl = null;
|
||
|
||
function setStatus(msg, kind) {
|
||
statusEl.textContent = msg || "";
|
||
statusEl.className = "status" + (kind ? " " + kind : "");
|
||
}
|
||
|
||
function showOriginal(file) {
|
||
origWrap.className = "";
|
||
origWrap.innerHTML = "";
|
||
const img = document.createElement("img");
|
||
img.src = URL.createObjectURL(file);
|
||
origWrap.appendChild(img);
|
||
}
|
||
|
||
function showResult(blob) {
|
||
if (lastResultUrl) URL.revokeObjectURL(lastResultUrl);
|
||
lastResultUrl = URL.createObjectURL(blob);
|
||
resultWrap.className = "";
|
||
resultWrap.innerHTML = "";
|
||
const img = document.createElement("img");
|
||
img.src = lastResultUrl;
|
||
resultWrap.appendChild(img);
|
||
const dl = document.createElement("div");
|
||
dl.className = "download";
|
||
const a = document.createElement("a");
|
||
a.href = lastResultUrl;
|
||
a.download = "result.png";
|
||
a.textContent = "下载 PNG";
|
||
dl.appendChild(a);
|
||
resultWrap.appendChild(dl);
|
||
}
|
||
|
||
async function loadEffects() {
|
||
try {
|
||
const res = await fetch("/effects");
|
||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||
const data = await res.json();
|
||
const ids = data.effects || [];
|
||
effectSelect.innerHTML = "";
|
||
if (ids.length === 0) {
|
||
effectSelect.innerHTML = '<option value="">无可用效果</option>';
|
||
setStatus("server/effects/ 下没有 PNG 文件", "error");
|
||
return;
|
||
}
|
||
for (const id of ids) {
|
||
const opt = document.createElement("option");
|
||
opt.value = String(id);
|
||
opt.textContent = String(id);
|
||
effectSelect.appendChild(opt);
|
||
}
|
||
} catch (e) {
|
||
effectSelect.innerHTML = '<option value="">加载失败</option>';
|
||
setStatus("加载效果列表失败:" + e.message, "error");
|
||
}
|
||
}
|
||
|
||
imageInput.addEventListener("change", () => {
|
||
const f = imageInput.files && imageInput.files[0];
|
||
if (f) {
|
||
showOriginal(f);
|
||
setStatus("");
|
||
}
|
||
});
|
||
|
||
submitBtn.addEventListener("click", async () => {
|
||
const file = imageInput.files && imageInput.files[0];
|
||
const effectId = effectSelect.value;
|
||
if (!file) { setStatus("请先选择图片", "error"); return; }
|
||
if (!effectId) { setStatus("请选择效果 ID", "error"); return; }
|
||
|
||
submitBtn.disabled = true;
|
||
setStatus("渲染中...");
|
||
const t0 = performance.now();
|
||
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append("image", file);
|
||
fd.append("effect_id", effectId);
|
||
const res = await fetch("/render", { method: "POST", body: fd });
|
||
|
||
if (!res.ok) {
|
||
let msg = "HTTP " + res.status;
|
||
try {
|
||
const j = await res.json();
|
||
if (j.error === "no_face") msg = "未检测到人脸";
|
||
else if (j.detail) msg = j.detail;
|
||
else if (j.error) msg = j.error;
|
||
} catch (_) { /* 非 JSON 响应 */ }
|
||
throw new Error(msg);
|
||
}
|
||
|
||
const blob = await res.blob();
|
||
showResult(blob);
|
||
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
||
setStatus(`完成(${dt}s)`, "ok");
|
||
} catch (e) {
|
||
setStatus("失败:" + e.message, "error");
|
||
} finally {
|
||
submitBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
loadEffects();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|