save code
This commit is contained in:
+13
-1
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user