Spaces:
Running
Running
| """ | |
| 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('<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() | |