save code

This commit is contained in:
xsl
2026-05-05 22:37:49 +08:00
parent bd1b7e66c8
commit 4e25641522
4 changed files with 458 additions and 1 deletions
+13 -1
View File
@@ -13,7 +13,8 @@ from __future__ import annotations
from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import JSONResponse, Response
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from .face_renderer import (
EffectNotFoundError,
@@ -32,6 +33,17 @@ _PATHS = RendererPaths(
app = FastAPI(title="Face SDK Web", version="0.1.0")
renderer = FaceRenderer(_PATHS)
_STATIC_DIR = _SERVER_DIR / "static"
@app.get("/")
def index() -> FileResponse:
return FileResponse(_STATIC_DIR / "index.html")
# /static/* 提供其它资源(CSS/JS 拆分时用得上;当前 index.html 内联)
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
@app.get("/health")
def health() -> dict:
+310
View File
@@ -0,0 +1,310 @@
<!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>