model-optimizer / app.py
wangyiyi666's picture
Upload folder using huggingface_hub
18a22ea verified
Raw
History Blame Contribute Delete
34.4 kB
"""
3D 模型优化服务 - HF Space 版本
Gradio + HF Hub API | 含用户建议反馈
"""
import os
import json
import uuid
import time
from datetime import datetime
import gradio as gr
from huggingface_hub import HfApi, list_repo_files
from feedback_util import fetch_public_feedback, submit_feedback
# === 预览配置 ===
PREVIEW_FORMATS = [".glb", ".gltf", ".obj"]
HF_RESOLVE_BASE = "https://huggingface.co/datasets"
# === 配置 ===
REPO_ID = "wangyiyi666/model-optimizer-queue"
REPO_TYPE = "dataset"
HF_TOKEN = os.environ.get("HF_TOKEN", "")
SUPPORTED_FORMATS = [".glb", ".gltf", ".fbx", ".obj"]
api = HfApi(token=HF_TOKEN)
# === 文件列表缓存(避免重复调用 HF API,防止超时/限流) ===
_files_cache = {"files": None, "ts": 0}
_FILES_CACHE_SEC = 10 # 缓存 10 秒
def _get_repo_files(force=False):
"""获取仓库文件列表(带缓存 + 超时保护)"""
global _files_cache
now = time.time()
if not force and _files_cache["files"] and (now - _files_cache["ts"]) < _FILES_CACHE_SEC:
return _files_cache["files"]
import concurrent.futures
def _fetch():
return list(list_repo_files(REPO_ID, repo_type=REPO_TYPE, token=HF_TOKEN))
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(_fetch)
try:
files = future.result(timeout=30) # 最多等 30 秒
except concurrent.futures.TimeoutError:
if _files_cache["files"]:
return _files_cache["files"] # 超时时返回旧缓存
raise TimeoutError("HF API 响应超时,请稍后重试")
_files_cache = {"files": files, "ts": now}
return files
def get_stats():
"""获取任务统计数据"""
try:
files = _get_repo_files()
inbox_models = [f for f in files if f.startswith("inbox/") and not f.endswith(".json") and not f.endswith(".gitkeep")]
outbox_models = [f for f in files if f.startswith("outbox/") and not f.endswith(".json") and not f.endswith(".gitkeep")]
total = len(inbox_models) + len(outbox_models)
done = len(outbox_models)
pending = len(inbox_models)
return f"📦 上传总数: **{total}** &nbsp;|&nbsp; ⏳ 待处理: **{pending}** &nbsp;|&nbsp; ✅ 已回传: **{done}**"
except Exception as e:
return f"统计加载失败: {e}"
def log(msg):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{timestamp}] [SPACE] {msg}")
# === 场景描述选项定义 ===
SCENE_TYPES = [
"游戏 - 实时渲染角色/道具",
"电商 - 3D 商品展示",
"建筑/室内 - BIM 可视化",
"数字人/虚拟主播",
"Web/小程序 - 轻量 3D",
"影视/离线渲染",
"3D 打印",
"其他 / 仅测试",
]
TARGET_PLATFORMS = [
"移动端(iOS/Android)",
"PC 端",
"Web 浏览器",
"VR/AR 头显",
"游戏主机",
"不确定",
]
FACE_BUDGETS = [
"不确定,由优化师决定",
"极轻量 (< 5K faces)",
"轻量 (5K - 30K)",
"中等 (30K - 100K)",
"高精度 (100K - 500K)",
"不限 / 尽量保留细节",
]
def upload_model(file, scene_type, target_platform, face_budget, scene_note, progress=gr.Progress()):
"""处理用户上传的模型文件(含场景描述)"""
if file is None:
return "### ⚠️ 请先选择文件", ""
# 校验必填项
if not scene_type:
return "### ⚠️ 请选择「使用场景」后再提交", ""
if not target_platform or len(target_platform) == 0:
return "### ⚠️ 请至少选择一个「目标平台」", ""
filename = os.path.basename(file.name if hasattr(file, "name") else file)
ext = os.path.splitext(filename)[1].lower()
log(f"用户上传: {filename}")
if ext not in SUPPORTED_FORMATS:
log(f"格式不支持: {ext}")
return f"### ❌ 不支持的格式: `{ext}`\n\n支持的格式: {', '.join(SUPPORTED_FORMATS)}", ""
task_id = uuid.uuid4().hex
target_name = f"{task_id}{ext}"
try:
progress(0.3, desc="正在上传模型文件...")
file_path = file.name if hasattr(file, "name") else file
api.upload_file(
path_or_fileobj=file_path,
path_in_repo=f"inbox/{target_name}",
repo_id=REPO_ID,
repo_type=REPO_TYPE,
)
progress(0.7, desc="正在创建任务...")
name_no_ext = os.path.splitext(filename)[0]
meta = json.dumps({
"task_id": task_id,
"filename": filename,
"name_no_ext": name_no_ext,
"scene_type": scene_type,
"target_platform": target_platform if isinstance(target_platform, list) else [target_platform],
"face_budget": face_budget or "不确定,由优化师决定",
"scene_note": (scene_note or "").strip(),
"status": "pending",
"created": str(datetime.now()),
}, ensure_ascii=False)
api.upload_file(
path_or_fileobj=meta.encode("utf-8"),
path_in_repo=f"inbox/{task_id}.json",
repo_id=REPO_ID,
repo_type=REPO_TYPE,
)
progress(1.0, desc="上传完成!")
log(f"上传成功: {target_name}, 任务ID: {task_id}")
file_size = os.path.getsize(file_path)
size_str = f"{file_size / 1024:.1f} KB" if file_size < 1024 * 1024 else f"{file_size / 1024 / 1024:.1f} MB"
return (
f"### ✅ **上传成功!**\n\n"
f"| 项目 | 信息 |\n"
f"|------|------|\n"
f"| 📋 任务ID | `{task_id}` |\n"
f"| 📁 文件名 | {filename} |\n"
f"| 📦 文件大小 | {size_str} |\n"
f"| 🎯 使用场景 | {scene_type} |\n"
f"| 📱 目标平台 | {', '.join(target_platform) if isinstance(target_platform, list) else target_platform} |\n"
f"| ⏱️ 状态 | 等待优化处理 |\n\n"
f"> ⚠️ **务必保存好任务ID,这是您下载优化结果的唯一凭证!**"
), task_id
except Exception as e:
log(f"上传失败: {e}")
return f"### ❌ 上传失败\n\n```\n{str(e)}\n```", ""
def _parse_glb_meshes(repo_path: str) -> list:
"""从 GLB 文件头解析每个网格的三角面数,返回 [(mesh_name, tris), ...](只下载 JSON chunk)"""
import struct
import requests as _req
try:
url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{repo_path}"
headers = {"Range": "bytes=0-2097151"} # 前 2MB
if HF_TOKEN:
headers["Authorization"] = f"Bearer {HF_TOKEN}"
resp = _req.get(url, headers=headers, timeout=15)
data = resp.content
if len(data) < 20:
return []
json_len = struct.unpack_from('<I', data, 12)[0]
if json_len > len(data) - 20:
del headers["Range"]
resp = _req.get(url, headers=headers, timeout=60)
data = resp.content
json_len = struct.unpack_from('<I', data, 12)[0]
json_bytes = data[20:20 + json_len]
gltf = json.loads(json_bytes.decode('utf-8'))
accessors = gltf.get('accessors', [])
result = []
for i, mesh in enumerate(gltf.get('meshes', [])):
tris = 0
for prim in mesh.get('primitives', []):
if 'indices' in prim:
tris += accessors[prim['indices']].get('count', 0) // 3
elif 'attributes' in prim and 'POSITION' in prim['attributes']:
tris += accessors[prim['attributes']['POSITION']].get('count', 0) // 3
result.append((mesh.get('name') or f"mesh{i}", tris))
return result
except Exception as e:
log(f"面数统计失败 {repo_path}: {e}")
return []
def _count_glb_faces(repo_path: str) -> int:
"""GLB 总三角面数"""
return sum(t for _, t in _parse_glb_meshes(repo_path))
def _build_viewer_html(inbox_path: str | None, outbox_path: str | None) -> str:
"""生成 <model-viewer> 双视口 HTML,含面数统计"""
import random
uid = random.randint(10000, 99999)
def _viewer(label: str, repo_path: str | None, viewer_id: str, show_lod: bool = False) -> str:
if not repo_path:
return ""
ext = os.path.splitext(repo_path)[1].lower()
if ext not in PREVIEW_FORMATS:
return (
f'<div style="flex:1;min-width:280px;">'
f'<div style="font-weight:600;margin-bottom:8px;">{label}</div>'
f'<div style="height:400px;background:#2E3340;border-radius:12px;display:flex;align-items:center;justify-content:center;color:#aaa;font-size:14px;">'
f'\u26a0\ufe0f \u683c\u5f0f {ext.upper()} \u4e0d\u652f\u6301\u5728\u7ebf\u9884\u89c8</div></div>'
)
url = f"{HF_RESOLVE_BASE}/{REPO_ID}/resolve/main/{repo_path}"
# 服务端计算面数(解析一次网格)
meshes = _parse_glb_meshes(repo_path)
faces = sum(t for _, t in meshes)
if faces > 0:
face_label = f"{faces/1000000:.1f}M" if faces >= 1000000 else f"{faces/1000:.1f}K" if faces >= 1000 else str(faces)
stats_html = f'\u25b3 <b>{faces:,}</b> faces ({face_label})'
if show_lod:
import re
lods = []
for name, tris in meshes:
m = re.search(r'LOD\s*(\d+)', name or '', re.I)
if m:
lods.append((int(m.group(1)), tris))
if len(lods) >= 2:
lods.sort(key=lambda x: x[0])
parts = ' \u00b7 '.join(f'LOD{n}: {t:,}' for n, t in lods)
stats_html = f'{parts}<br>\u5408计 {faces:,}'
else:
stats_html = ''
return (
f'<div style="flex:1;min-width:280px;">'
f'<div style="font-weight:600;margin-bottom:8px;">{label}</div>'
f'<model-viewer id="{viewer_id}" src="{url}" '
f'camera-controls auto-rotate '
f'shadow-intensity="0.7" exposure="1.0" environment-image="neutral" '
f'style="width:100%;height:400px;background:#2E3340;border-radius:12px;"'
f'></model-viewer>'
f'<div style="margin-top:6px;padding:6px 12px;'
f'background:rgba(12,15,28,0.6);border:1px solid rgba(100,180,255,0.12);'
f'border-radius:8px;color:#80d4ff;font-size:13px;font-family:monospace;'
f'text-align:center;min-height:24px;">{stats_html}</div>'
f'</div>'
)
vid_in = f"mv-in-{uid}"
vid_out = f"mv-out-{uid}"
left = _viewer("📤 原始模型", inbox_path, vid_in)
right = _viewer("✨ 优化后模型", outbox_path, vid_out, show_lod=True)
if not left and not right:
return ""
return f'<div style="display:flex;gap:16px;flex-wrap:wrap;">{left}{right}</div>'
def check_status(task_id):
"""查询任务状态,同时返回模型预览"""
hide_btn = gr.update(visible=False)
no_preview = ""
if not task_id or len(task_id.strip()) == 0:
return "### ⚠️ 请输入任务ID", hide_btn, no_preview
task_id = task_id.strip()
log(f"查询: {task_id}")
try:
files = _get_repo_files(force=True)
inbox_model_files = [
f for f in files
if f.startswith(f"inbox/{task_id}") and not f.endswith(".json")
]
outbox_matches = [
f for f in files
if f.startswith(f"outbox/{task_id}") and not f.endswith(".json")
]
if outbox_matches:
result_path = outbox_matches[0]
ext = os.path.splitext(result_path)[1].upper().lstrip(".")
log(f"任务 {task_id} 已完成")
viewer_html = _build_viewer_html(
inbox_model_files[0] if inbox_model_files else None,
result_path,
)
status_md = (
f"### ✅ 优化完成!\n\n"
f"| 项目 | 信息 |\n"
f"|------|------|\n"
f"| 📋 任务ID | `{task_id}` |\n"
f"| 📄 输出格式 | {ext} |\n\n"
f"> 👇 点击下方「下载模型」按钮获取优化后的文件"
)
return status_md, gr.update(visible=True), viewer_html
inbox_all = [f for f in files if f.startswith(f"inbox/{task_id}")]
if inbox_all:
log(f"任务 {task_id} 仍在队列中")
viewer_html = _build_viewer_html(
inbox_model_files[0] if inbox_model_files else None,
None,
)
status_md = (
f"### ⏳ 处理中\n\n"
f"任务 `{task_id}` 正在优化队列中,请稍后再查询。\n\n"
f"> Worker 每 **30秒** 检查一次新任务,优化完成后即可下载。"
)
return status_md, hide_btn, viewer_html
log(f"任务 {task_id} 未找到")
return f"### ❌ 未找到任务\n\n任务ID `{task_id}` 不存在,请检查是否输入正确。", hide_btn, no_preview
except Exception as e:
log(f"查询出错: {e}")
return f"### ❌ 查询出错\n\n```\n{str(e)}\n```", hide_btn, no_preview
def download_model(task_id):
"""下载模型并记录取件事件"""
if not task_id or len(task_id.strip()) == 0:
return None, "### ⚠️ 请先查询任务ID"
task_id = task_id.strip()
log(f"用户下载: {task_id}")
try:
files = _get_repo_files()
outbox_matches = [f for f in files if f.startswith(f"outbox/{task_id}") and not f.endswith(".json")]
if not outbox_matches:
return None, "### ❌ 未找到优化结果"
result_path = outbox_matches[0]
download_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{result_path}"
# 记录下载事件到 HF Dataset
from datetime import datetime
record = json.dumps({
"task_id": task_id,
"downloaded_at": datetime.now().isoformat(),
"result_file": result_path,
})
try:
api.upload_file(
path_or_fileobj=record.encode(),
path_in_repo=f"outbox/{task_id}.downloaded.json",
repo_id=REPO_ID,
repo_type=REPO_TYPE,
)
log(f"已记录下载: {task_id}")
except Exception as e:
log(f"记录下载失败(不影响下载): {e}")
return (
None,
f"### ✅ 下载链接已生成\n\n"
f"> 👇 [点击此处下载优化后的模型]({download_url})\n\n"
f"*下载记录已通知管理端*"
)
except Exception as e:
log(f"下载出错: {e}")
return None, f"### ❌ 下载出错\n\n```\n{str(e)}\n```"
def delete_task(task_id):
"""删除任务(清理 inbox + outbox 文件)"""
if not task_id or len(task_id.strip()) == 0:
return "### ⚠️ 请输入任务ID"
task_id = task_id.strip()
log(f"删除任务: {task_id}")
try:
files = _get_repo_files(force=True)
to_delete = [f for f in files if task_id in f and f != ".gitattributes"]
if not to_delete:
return f"### ❌ 未找到任务\n\n任务ID `{task_id}` 不存在。"
deleted = []
for filepath in to_delete:
try:
api.delete_file(filepath, REPO_ID, repo_type=REPO_TYPE)
deleted.append(filepath)
log(f"已删除: {filepath}")
except Exception as e:
log(f"删除失败 {filepath}: {e}")
file_list = "\n".join([f"- `{f}`" for f in deleted])
return f"### 🗑️ 删除成功\n\n已删除 **{len(deleted)}** 个文件:\n\n{file_list}"
except Exception as e:
log(f"删除出错: {e}")
return f"### ❌ 删除出错\n\n```\n{str(e)}\n```"
def render_public_feedback():
items = fetch_public_feedback()
if not items:
return "<p style='color:#888;'>暂无公开反馈</p>"
blocks = []
for item in items:
created = (item.get("created") or "")[:19]
username = item.get("username") or "匿名"
text = (item.get("text") or "").replace("\n", "<br>")
img_note = ""
if item.get("images"):
img_note = f"<div style='color:#666;font-size:13px;margin-top:6px;'>附带 {len(item['images'])} 张图片</div>"
blocks.append(
f"<div style='border:1px solid #eee;border-radius:8px;padding:14px;margin-bottom:12px;background:#fafafa;'>"
f"<div style='font-weight:600;'>{username} "
f"<span style='color:#999;font-size:12px;'>{created}</span></div>"
f"<div style='margin-top:8px;line-height:1.6;'>{text}</div>{img_note}</div>"
)
return "".join(blocks)
def handle_feedback_submit(username, text, images, is_public):
try:
image_paths = []
if images:
if isinstance(images, list):
image_paths = [item.name if hasattr(item, "name") else item for item in images]
else:
image_paths = [images.name if hasattr(images, "name") else images]
meta = submit_feedback(username, text, image_paths, is_public)
visibility = "已公开" if meta.get("is_public") else "仅管理员可见"
return f"### ✅ 反馈提交成功\n\n| 项目 | 信息 |\n|------|------|\n| 反馈ID | `{meta['feedback_id']}` |\n| 展示范围 | {visibility} |"
except Exception as e:
log(f"反馈提交失败: {e}")
return f"### ❌ 提交失败\n\n```\n{str(e)}\n```"
# ---------- 读取流体背景脚本 ----------
_fluid_js_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fluid_bg.js")
_fluid_js = ""
if os.path.exists(_fluid_js_path):
with open(_fluid_js_path, encoding="utf-8") as _f:
_fluid_js = _f.read()
_head_html = (
'<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>'
+ f'<script>{_fluid_js}</script>' if _fluid_js else
'<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>'
)
custom_css = """
/* ===== dark glass-morphism theme ===== */
body { background: #05060a !important; }
body.fluid-off { background: #000 !important; }
footer { display: none !important; }
/* --- Gradio wrapper chain: ALL transparent so fluid canvas shows through --- */
body > div,
body > div > div,
#root,
.gradio-container > .main,
.gradio-container > .wrap,
.gradio-container > .contain,
.app {
background: transparent !important;
background-color: transparent !important;
}
.gradio-container {
position: relative; z-index: 5;
max-width: 1200px !important;
width: 88% !important;
margin: 0 auto !important;
padding: 28px 40px !important;
background: transparent !important;
border-radius: 18px !important;
border: 1px solid transparent !important;
margin-top: 20px !important;
margin-bottom: 20px !important;
transition: background 0.4s ease, border-color 0.4s ease, backdrop-filter 0.4s ease !important;
}
.gradio-container:focus-within {
background: rgba(8, 10, 20, 0.75) !important;
backdrop-filter: blur(20px) saturate(1.2) !important;
-webkit-backdrop-filter: blur(20px) saturate(1.2) !important;
border-color: rgba(100, 180, 255, 0.08) !important;
}
/* panels — glass morphism only on interaction blocks */
.gr-panel, .gr-box, .gr-form, .gr-input-label,
.gr-padded {
background: rgba(12, 15, 28, 0.65) !important;
border: 1px solid rgba(100, 180, 255, 0.10) !important;
border-radius: 14px !important;
backdrop-filter: blur(18px) saturate(1.3) !important;
-webkit-backdrop-filter: blur(18px) saturate(1.3) !important;
}
/* Gradio 4.x structural wrappers — keep transparent */
.gr-block, .gr-group, .gr-row, .block {
background: transparent !important;
background-color: transparent !important;
}
/* Svelte 内部包装器透明(排除 checkbox/radio 容器) */
.gradio-container [class*="svelte"]:not([role="group"]):not(.wrap) {
background: transparent !important;
background-color: transparent !important;
}
.tabitem > div, .tab-content, .form, .block.padded {
background: transparent !important;
}
/* checkbox / radio 组件保证可点击 */
.gr-check-radio, .gr-checkbox-group,
label:has(input[type="checkbox"]), label:has(input[type="radio"]) {
pointer-events: auto !important;
position: relative !important;
z-index: 10 !important;
}
input[type="checkbox"], input[type="radio"] {
pointer-events: auto !important;
cursor: pointer !important;
position: relative !important;
z-index: 10 !important;
}
/* re-apply glass on input blocks */
.gr-block.gr-box {
background: rgba(12, 15, 28, 0.65) !important;
}
/* tabs */
.tabs { background: transparent !important; }
.tabitem { background: transparent !important; }
.tab-nav { background: transparent !important; border-bottom: 1px solid rgba(100,180,255,0.12) !important; }
.tab-nav button {
color: rgba(180, 210, 255, 0.6) !important;
background: transparent !important;
border: none !important;
font-weight: 500;
}
.tab-nav button.selected {
color: #60c8ff !important;
border-bottom: 2px solid #60c8ff !important;
background: rgba(96, 200, 255, 0.06) !important;
}
/* inputs */
input, textarea, .gr-input {
background: rgba(15, 20, 40, 0.7) !important;
border: 1px solid rgba(100, 180, 255, 0.15) !important;
color: #d0e0ff !important;
border-radius: 10px !important;
}
input:focus, textarea:focus {
border-color: rgba(96, 200, 255, 0.5) !important;
box-shadow: 0 0 12px rgba(96, 200, 255, 0.15) !important;
}
/* dropdown 下拉框组件 — 全部透明/暗色 */
[data-testid="dropdown"], .gr-dropdown,
[role="listbox"], ul[role="listbox"] {
background: rgba(15, 20, 40, 0.95) !important;
border: 1px solid rgba(100, 180, 255, 0.15) !important;
border-radius: 10px !important;
}
[data-testid="dropdown"] > *, .gr-dropdown > *,
[data-testid="dropdown"] button,
.gr-dropdown button,
.wrap-inner, .secondary-wrap, .icon-wrap {
background: transparent !important;
background-color: transparent !important;
}
/* 下拉框输入区域透明 */
[data-testid="dropdown"] input,
.gr-dropdown input {
background: transparent !important;
background-color: transparent !important;
}
/* 下拉框外层包装器透明 */
.gradio-container .wrap,
.gradio-container div[data-testid] > div {
background: transparent !important;
background-color: transparent !important;
}
[role="option"], [role="listbox"] li {
background: rgba(15, 20, 40, 0.95) !important;
color: #d0e0ff !important;
}
[role="option"]:hover, [role="listbox"] li:hover {
background: rgba(96, 200, 255, 0.15) !important;
}
[role="option"][aria-selected="true"] {
background: rgba(96, 200, 255, 0.2) !important;
color: #80d4ff !important;
}
/* buttons — 只针对 Gradio 功能按钮,排除内部组件小按钮 */
.gr-button, button.primary, button.secondary, button.lg, button.sm, button.stop {
min-height: 46px !important;
padding: 10px 24px !important;
font-size: 16px !important;
font-weight: 500 !important;
border-radius: 12px !important;
transition: all 0.3s ease !important;
}
button.primary, button.lg.primary {
background: linear-gradient(135deg, rgba(60,140,255,0.75), rgba(100,80,220,0.75)) !important;
border: 1px solid rgba(100, 180, 255, 0.3) !important;
color: #fff !important;
backdrop-filter: blur(8px) !important;
font-size: 17px !important;
min-height: 52px !important;
padding: 12px 28px !important;
}
button.primary:hover {
background: linear-gradient(135deg, rgba(80,160,255,0.9), rgba(120,100,240,0.9)) !important;
box-shadow: 0 0 24px rgba(96, 200, 255, 0.3) !important;
transform: translateY(-1px);
}
button.secondary, button.stop {
background: rgba(20, 25, 50, 0.6) !important;
border: 1px solid rgba(100, 180, 255, 0.18) !important;
color: #a0c0e8 !important;
backdrop-filter: blur(8px) !important;
font-size: 16px !important;
min-height: 48px !important;
}
button.sm {
background: rgba(20, 25, 50, 0.5) !important;
border: 1px solid rgba(100, 180, 255, 0.12) !important;
color: #80b0e0 !important;
font-size: 14px !important;
min-height: 38px !important;
padding: 6px 16px !important;
}
/* 文件组件内部的 X 删除按钮 — 保持小尺寸 */
.file-preview button, .upload-container button, button.remove-file {
min-height: unset !important;
padding: 2px 6px !important;
font-size: 12px !important;
border-radius: 50% !important;
min-width: unset !important;
width: 24px !important;
height: 24px !important;
backdrop-filter: none !important;
}
/* text colors & sizing */
.gr-markdown, .gr-markdown *, .prose, .prose * {
color: #c8daf0 !important;
font-size: 15px !important;
line-height: 1.6 !important;
}
.gr-markdown h1, .gr-markdown h2, .gr-markdown h3 {
color: #e0f0ff !important;
}
.gr-markdown h1 { font-size: 22px !important; }
.gr-markdown h2 { font-size: 19px !important; }
.gr-markdown h3 { font-size: 17px !important; }
.gr-markdown strong { color: #80d4ff !important; }
.gr-markdown a { color: #60c8ff !important; }
label, .gr-input-label span {
color: #90b8dc !important;
font-size: 14px !important;
}
input, textarea {
font-size: 14px !important;
}
/* title section */
.main-title { text-align: center; margin-bottom: 0.5em; }
.main-title h1 {
color: #e8f4ff !important;
text-shadow: 0 0 30px rgba(96,200,255,0.2);
font-size: 28px !important;
}
.sub-title {
color: rgba(180, 210, 255, 0.6) !important;
font-size: 15px !important;
margin-bottom: 1.5em;
text-align: center;
}
.format-badge {
display: inline-block;
background: rgba(96, 200, 255, 0.12);
color: #60c8ff;
padding: 5px 16px; border-radius: 16px; margin: 3px;
font-size: 13px; font-weight: 500;
border: 1px solid rgba(96, 200, 255, 0.18);
backdrop-filter: blur(6px);
}
/* tab nav text */
.tab-nav button {
font-size: 14px !important;
}
/* file upload */
.file-preview, .upload-area, [data-testid="file"] {
background: rgba(12, 15, 28, 0.5) !important;
border: 1px dashed rgba(100, 180, 255, 0.2) !important;
border-radius: 12px !important;
}
/* checkbox — 增大并显示明确勾选 */
.gr-check-radio input[type=checkbox],
input[type=checkbox] {
accent-color: #60c8ff !important;
width: 18px !important;
height: 18px !important;
cursor: pointer !important;
border: 2px solid rgba(100, 180, 255, 0.4) !important;
border-radius: 4px !important;
appearance: auto !important;
-webkit-appearance: checkbox !important;
}
input[type=checkbox]:checked {
background: #60c8ff !important;
border-color: #60c8ff !important;
}
/* 选中项 label 高亮 */
label:has(input[type=checkbox]:checked) {
color: #80d4ff !important;
font-weight: 600 !important;
}
/* scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: rgba(5,6,10,0.4); }
::-webkit-scrollbar-thumb { background: rgba(96,200,255,0.2); border-radius: 3px; }
/* stats row */
.stats-row {
background: rgba(12, 15, 28, 0.55) !important;
border: 1px solid rgba(100, 180, 255, 0.10) !important;
border-radius: 12px !important;
backdrop-filter: blur(14px) !important;
padding: 8px 16px;
}
/* model preview containers */
model-viewer {
border: 1px solid rgba(100, 180, 255, 0.12) !important;
}
"""
with gr.Blocks(
title="3D 模型优化服务",
theme=gr.themes.Soft(primary_hue="blue", secondary_hue="slate"),
css=custom_css,
head=_head_html,
) as demo:
gr.HTML("""
<div class="main-title"><h1>🛠️ 3D 模型优化服务</h1></div>
<div class="sub-title">上传 3D 模型 · 自动优化处理 · 完成后下载</div>
<div style="text-align:center;margin-bottom:1.5em;">
<span class="format-badge">GLB</span><span class="format-badge">GLTF</span>
<span class="format-badge">FBX</span><span class="format-badge">OBJ</span>
<span style="margin:0 8px;color:rgba(150,200,255,0.4);">→</span>
<span class="format-badge">GLB 输出</span>
</div>
<p style="text-align:center;color:rgba(255,180,100,0.85);">⚠️ 测试阶段,请优先上传 GLB 格式文件</p>
""")
with gr.Row():
stats_display = gr.Markdown(value=get_stats())
refresh_stats_btn = gr.Button("🔄 刷新统计", size="sm", scale=0)
refresh_stats_btn.click(get_stats, outputs=[stats_display])
with gr.Tabs():
with gr.Tab("📤 上传模型", id="upload"):
with gr.Row(equal_height=True):
with gr.Column(scale=1):
file_input = gr.File(
label="选择 3D 模型文件",
file_types=[".glb", ".gltf", ".fbx", ".obj"],
type="filepath",
height=150,
)
gr.Markdown("#### 📝 场景描述(必填)", elem_classes=["scene-title"])
scene_type_input = gr.Dropdown(
label="使用场景",
choices=SCENE_TYPES,
value=None,
info="选择模型的使用场景,便于针对性优化",
)
target_platform_input = gr.CheckboxGroup(
label="目标平台(可多选)",
choices=TARGET_PLATFORMS,
value=[],
info="模型最终运行的平台",
)
face_budget_input = gr.Dropdown(
label="期望面数范围(可选)",
choices=FACE_BUDGETS,
value="不确定,由优化师决定",
)
scene_note_input = gr.Textbox(
label="补充说明(可选)",
placeholder="如:需要保留骨骼动画、关注面部精度、单场景有多个同类模型...",
lines=2,
max_lines=4,
)
upload_btn = gr.Button("🚀 提交优化", variant="primary", size="lg")
with gr.Column(scale=1):
upload_result = gr.Markdown(
value="### 📋 等待上传\n\n选择文件并填写场景描述后点击「提交优化」按钮。",
label="处理结果",
)
task_id_output = gr.Textbox(label="📋 任务ID(复制保存)", interactive=False)
upload_btn.click(
upload_model,
inputs=[file_input, scene_type_input, target_platform_input, face_budget_input, scene_note_input],
outputs=[upload_result, task_id_output],
)
with gr.Tab("🔍 查询结果", id="query"):
with gr.Row(equal_height=True):
with gr.Column(scale=1):
task_id_input = gr.Textbox(label="输入任务ID", placeholder="粘贴完整任务ID", max_lines=1)
with gr.Row():
check_btn = gr.Button("🔍 查询状态", variant="primary", size="lg")
delete_btn = gr.Button("🗑️ 删除任务", variant="stop", size="lg")
download_btn = gr.Button("📥 下载模型", variant="secondary", size="lg", visible=False)
with gr.Column(scale=1):
status_result = gr.Markdown(
value="### 📋 等待查询\n\n输入任务ID后点击「查询状态」按钮。",
label="任务状态",
)
gr.Markdown("### 👁️ 模型预览")
model_preview_html = gr.HTML(value="")
check_btn.click(
check_status,
inputs=[task_id_input],
outputs=[status_result, download_btn, model_preview_html],
)
delete_btn.click(delete_task, inputs=[task_id_input], outputs=[status_result])
download_btn.click(download_model, inputs=[task_id_input], outputs=[download_btn, status_result])
with gr.Tab("💬 提交反馈", id="feedback"):
gr.Markdown("欢迎提交使用建议或问题反馈,可附带截图。管理员会在本地管理面板查看全部反馈。")
feedback_username = gr.Textbox(label="用户名 / 昵称", placeholder="可选,默认匿名")
feedback_text = gr.Textbox(label="反馈内容", lines=6, placeholder="请描述您的建议或遇到的问题...")
feedback_images = gr.File(
label="截图 / 图片(可选,可多选)",
file_count="multiple",
file_types=["image"],
type="filepath",
)
feedback_public = gr.Checkbox(
label="允许对外公开展示(勾选后其他用户可在「公开反馈」页看到文字内容)",
value=False,
)
feedback_submit_btn = gr.Button("提交反馈", variant="primary")
feedback_result = gr.Markdown()
feedback_submit_btn.click(
handle_feedback_submit,
inputs=[feedback_username, feedback_text, feedback_images, feedback_public],
outputs=[feedback_result],
)
with gr.Tab("📣 公开反馈", id="public_feedback"):
refresh_public_btn = gr.Button("🔄 刷新公开反馈")
public_feedback_html = gr.HTML(value=render_public_feedback())
refresh_public_btn.click(lambda: render_public_feedback(), outputs=[public_feedback_html])
gr.HTML("<p style='text-align:center;color:rgba(150,180,220,0.35);margin-top:1em;font-size:0.8em;'>Fluid effect inspired by <a href=\"https://github.com/PavelDoGreat/WebGL-Fluid-Simulation\" target=\"_blank\" style=\"color:rgba(96,200,255,0.4);text-decoration:none;\">WebGL-Fluid-Simulation</a></p>")
if __name__ == "__main__":
log("Gradio 前端启动")
demo.launch()