""" 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}**  |  ⏳ 待处理: **{pending}**  |  ✅ 已回传: **{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(' len(data) - 20: del headers["Range"] resp = _req.get(url, headers=headers, timeout=60) data = resp.content json_len = struct.unpack_from(' 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: """生成 双视口 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'
' f'
{label}
' f'
' f'\u26a0\ufe0f \u683c\u5f0f {ext.upper()} \u4e0d\u652f\u6301\u5728\u7ebf\u9884\u89c8
' ) 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 {faces:,} 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}
\u5408计 {faces:,}' else: stats_html = '' return ( f'
' f'
{label}
' f'' f'
{stats_html}
' f'
' ) 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'
{left}{right}
' 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 "

暂无公开反馈

" blocks = [] for item in items: created = (item.get("created") or "")[:19] username = item.get("username") or "匿名" text = (item.get("text") or "").replace("\n", "
") img_note = "" if item.get("images"): img_note = f"
附带 {len(item['images'])} 张图片
" blocks.append( f"
" f"
{username} " f"{created}
" f"
{text}
{img_note}
" ) 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 = ( '' + f'' if _fluid_js else '' ) 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("""

🛠️ 3D 模型优化服务

上传 3D 模型 · 自动优化处理 · 完成后下载
GLBGLTF FBXOBJ GLB 输出

⚠️ 测试阶段,请优先上传 GLB 格式文件

""") 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("

Fluid effect inspired by WebGL-Fluid-Simulation

") if __name__ == "__main__": log("Gradio 前端启动") demo.launch()