chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:15:38 +08:00
commit 88025fdd5c
76 changed files with 18514 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
TRIPO_API_KEY=your_tripo_api_key
TRIPO_MODEL_VERSION=v3.0-20250812
TRIPO_API_BASE=https://api.tripo3d.ai/v2/openapi
RODIN_API_KEY=your_rodin_api_key
RODIN_API_BASE=https://api.hyper3d.com/api/v2
RODIN_TIER=Gen-2
RODIN_QUALITY=medium
RODIN_MESH_MODE=Raw
RODIN_MATERIAL=PBR
FAL_API_KEY=your_fal_api_key
FAL_DEFAULT_MODEL=fal-ai/hunyuan3d/v2
VISION_PROVIDER=openai
OPENAI_API_KEY=your_openai_api_key
OPENAI_API_BASE=https://api.openai.com/v1
OPENAI_VISION_MODEL=gpt-4o-mini
HUNYUAN_API_BASE=http://127.0.0.1:8081
HUNYUAN_CREATE_PATH=/send
HUNYUAN_STATUS_PATH=/status
API_PORT=8787
API_HOST=127.0.0.1
LOCAL_MODEL_DIR=.generated-models
LOG_DIR=.logs
LOG_FILE=3d-model-studio-api.log
+5
View File
@@ -0,0 +1,5 @@
*.glb binary
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
+34
View File
@@ -0,0 +1,34 @@
# Logs
logs
.logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
.generated-models
test-results
playwright-report
*.local
.env
.env.*
!.env.example
# Local verification screenshots
bio-demo-*.png
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 huangserva
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+185
View File
@@ -0,0 +1,185 @@
# 3D Model Studio
[English](README.md) | [中文](README.zh-CN.md)
AI-powered interactive 3D model generation, inspection, and presentation studio.
3D Model Studio is a React + Three.js prototype for turning uploaded reference images or GLB files into a polished interactive 3D workspace. It supports live WebGL orbit controls, a left model library / center stage / right tools workbench, screenshots, GLB export, collapsed upload history, demo presentation mode, a generation queue, and optional image-to-3D providers for generating real 3D models from uploaded reference images.
## Demo
[![3D Model Studio demo](docs/demo/3DCellForge-demo-cover.jpg)](docs/demo/3DCellForge-demo-2026-05-10.mp4)
Open the demo video: [Demo MP4](docs/demo/3DCellForge-demo-2026-05-10.mp4)
## Features
- Interactive model viewer built with React Three Fiber.
- Three-column workbench: Model Library on the left, WebGL stage in the center, asset/generation tools on the right.
- Drag to rotate, scroll to zoom, isolate structure parts, inspect model details, and export the current scene.
- Object-aware inspector with inferred category, source, provider state, material focus, demo value, and tags for vehicles, aircraft, vessels, products, artifacts, and organic specimens.
- Model quality score for generated GLBs, including file size, triangle count, texture count, and demo readiness.
- Demo Mode for screenshots and screen recordings: hides side panels, uses object-aware cinematic camera paths, and shows a clean presentation overlay.
- Productized Model Library drawer with source thumbnails, provider/status, task id, GLB URL actions, comparison, and delete controls.
- Saved Assets stays collapsed by default, while the active generated/imported asset stays pinned and clickable.
- Generated/imported models are restored after refresh through IndexedDB, with localStorage as a compact fallback.
- Generic part detail drawer, asset references, comparison panel, notes, gallery actions, logs, saved projects, and a compact generation queue.
- Hyper3D, Tripo, Fal.ai, Hunyuan3D, JS Depth, and Local GLB generation/import modes.
- Cached demo GLB models for offline-friendly screenshots and demos.
- Auxiliary Khronos glTF reference models for GLB loader and PBR material checks.
- API key stays server-side in `.env.local`; it is never exposed to the frontend bundle.
## Tech Stack
- React
- Vite
- Three.js
- React Three Fiber
- Drei
- Framer Motion
- Tripo API optional backend
- Fal.ai optional backend
- Hunyuan3D local API optional backend
## Quick Start
```bash
npm install
npm run dev
```
Open the Vite URL shown in the terminal.
## Workbench Workflow
The default screen is intentionally quiet:
- Pick the active generated/imported asset from the left `Model Library` rail.
- Earlier generated/imported models are tucked under `Saved Assets` until expanded.
- Use the right `Asset Source` rail to choose the generation provider or import a local `.glb` / `.gltf`.
- Watch upload/generation/import state in the left `Generation Queue` panel.
- Click `Info` or `Inspect` only when you need the part detail drawer.
- Open top-nav `Library` for the full asset catalog with previews, provider state, task ids, GLB URL copy, provider comparison, and deletion.
- Click `Demo` in the top navigation to enter a clean presentation mode for screenshots and recordings.
- Check the quality card on the stage before recording; low scores usually mean the source image or provider result is not demo-ready.
- Demo animation adapts to the model name and metadata: cars use a road push-in, aircraft use a flight pass, ships/carriers use a naval cruise, and organic/specimen assets use a studio orbit.
Useful validation commands:
```bash
npm run lint
npm run build
npm run test
npm run test:visual
```
`npm run test:visual` runs Playwright layout and screenshot regression checks for the workbench, the Model Library drawer, and Demo Mode. Use `npm run test:visual:update` only when an intentional UI change needs new screenshot baselines.
## Optional Image-to-3D Backend
To enable image-to-3D generation, create `.env.local`:
```bash
cp .env.example .env.local
```
Then set:
```bash
TRIPO_API_KEY=your_tripo_key
FAL_API_KEY=your_fal_key
RODIN_API_KEY=your_rodin_api_key
OPENAI_API_KEY=your_openai_key
API_HOST=127.0.0.1
```
`OPENAI_API_KEY` enables optional image understanding through `/api/3d/analyze`. When configured, uploads are classified by vision into asset type, material focus, inspection notes, scene profile, tags, and a better image-to-3D prompt. Without it, the app keeps using local filename/metadata heuristics.
For Hunyuan3D local backup mode, start your local Hunyuan3D API server and set:
```bash
HUNYUAN_API_BASE=http://127.0.0.1:8081
HUNYUAN_CREATE_PATH=/send
HUNYUAN_STATUS_PATH=/status
```
The 3D generation backend supports these provider paths:
```text
Hyper3D Hyper3D Rodin cloud generation only (default)
Tripo Tripo cloud generation only
Fal Fal.ai queue generation; model is selected in Settings
Auto Hyper3D first, then Tripo, Fal, Hunyuan, and JS Depth backup
Hunyuan Local Hunyuan3D generation only
```
The upload panel exposes the full generation mode choice before picking a file:
```text
Hyper3D Hyper3D Rodin GLB generation
Tripo Tripo cloud GLB generation
Fal Fal.ai queue GLB generation
Hunyuan Local Hunyuan3D GLB generation
JS Depth Browser-side image relief with layered PNG fallback
Auto Hyper3D, Tripo, Fal, Hunyuan, then JS Depth fallback
Local GLB Import an existing .glb or self-contained .gltf
```
Tripo uploads use the current STS object-storage flow (`/upload/sts/token`) before creating an `image_to_model` task.
Fal uploads use the official `@fal-ai/client` storage and queue APIs. Supported Fal models are Hunyuan3D v2, TRELLIS, TripoSR, Tripo3D v2.5, and Hyper3D Rodin. Pick the active Fal model in `Settings`.
Rodin uploads use Hyper3D's multipart `/rodin` task API, then poll `/status` and cache the GLB returned by `/download`.
Generated GLBs are cached by the Node backend under `.generated-models/`, so later views use the local copy instead of temporary provider URLs.
The frontend model library is saved in IndexedDB, so successful generated/imported model records survive page refreshes.
You can also import a local `.glb` or self-contained `.gltf` from the `New Upload` button. Imported models become custom workspace models and are served from the same local cache.
Expected Hunyuan3D local API shape:
```text
POST /send
GET /status/:uid
```
The status response can return either a remote model URL or a base64 GLB field such as `model_base64` / `glb_base64`. Base64 GLBs are cached under `.generated-models/` and served by the Node backend.
Start the backend:
```bash
npm run dev:api
```
Then start the frontend:
```bash
npm run dev
```
The frontend talks to the local Node backend at `http://127.0.0.1:8787` by default.
## Demo Models
The repository includes cached generated GLB files under:
```text
public/generated-models/
```
These make the demo usable without spending API credits on every run.
## Reference Models
The Library panel includes remote Khronos glTF Sample Models as auxiliary references for material and loader checks:
- Transmission Test, CC0, Adobe via Khronos.
- Transmission Roughness Test, CC-BY 4.0, Ed Mackey / Analytical Graphics via Khronos.
- Mosquito In Amber, CC-BY 4.0, Loic Norgeot / Geoffrey Marchal / Sketchfab via Khronos.
These are loaded from the archived Khronos sample repository and are not bundled into this repo.
## Security
Do not put real API keys in frontend code. Keep secrets in `.env.local`, which is ignored by git.
## License
MIT
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`huangserva/3DCellForge`
- 原始仓库:https://github.com/huangserva/3DCellForge
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+185
View File
@@ -0,0 +1,185 @@
# 3D Model Studio
[English](README.md) | [中文](README.zh-CN.md)
AI 驱动的交互式 3D 模型生成、检查和演示工作台。
3D Model Studio 是一个 React + Three.js 原型,用于把上传图片或 GLB 文件变成可交互的 3D 模型工作区。它支持 WebGL 拖拽旋转、滚轮缩放、左侧模型库 / 中央 3D 舞台 / 右侧工具区、截图、GLB 导出、历史上传默认收起、Demo 演示模式、生成队列,以及通过 Hyper3D / Tripo / Fal.ai / Hunyuan3D / JS Depth / 本地模型导入生成或加载 3D 模型。
## 演示视频
[![3D Model Studio 演示视频](docs/demo/3DCellForge-demo-cover.jpg)](docs/demo/3DCellForge-demo-2026-05-10.mp4)
打开视频文件:[演示 MP4](docs/demo/3DCellForge-demo-2026-05-10.mp4)
## 功能
- 基于 React Three Fiber 的交互式模型查看器。
- 三栏工作台:左侧 Model Library,中间 WebGL 主舞台,右侧素材和生成工具。
- 支持拖拽旋转、滚轮缩放、结构隔离、部件 Inspect 和场景导出。
- 对象级说明面板会根据资产名称和生成元数据推断类别、来源、Provider 状态、材质重点、演示价值和标签,覆盖车辆、飞机、船舰、产品、文物和有机标本。
- 模型质量评分会展示 GLB 文件大小、三角面数、贴图数量和演示可用性。
- Demo Mode 会隐藏左右工具区、根据物体类型使用不同运镜,并显示干净的演示信息层,适合截图和录屏。
- Model Library 抽屉升级为资产库视图,包含源图预览、Provider / 状态、任务 ID、GLB URL 操作、Provider 对比和删除入口。
- Saved Assets 默认收起,当前激活的生成 / 导入资产会固定显示并可直接点击打开。
- 生成 / 导入成功的模型会写入 IndexedDB,刷新页面后会自动恢复;localStorage 只做轻量兜底。
- 自定义上传记录支持删除,并同步清理相关本地数据。
- 通用部件详情抽屉、素材参考、对比面板、模型笔记、图库操作、日志、项目保存和生成队列。
- 支持 Hyper3D、Tripo、Fal.ai、Hunyuan3D、JS Depth 和 Local GLB 多种模式。
- 生成后的 GLB 会缓存到本地,方便后续演示和截图。
- 内置 Khronos glTF 辅助参考模型,用于检查 GLB 加载和 PBR 材质表现。
- API Key 只放在服务端 `.env.local`,不会暴露到前端包里。
## 技术栈
- React
- Vite
- Three.js
- React Three Fiber
- Drei
- Framer Motion
- Tripo API 可选后端
- Fal.ai 可选后端
- Hunyuan3D 本地 API 可选后端
## 快速开始
```bash
npm install
npm run dev
```
打开终端里显示的 Vite 地址即可。
## 工作台流程
默认页面会尽量减少干扰:
- 左侧 `Model Library` 固定展示当前激活的生成 / 导入资产。
- 更早生成、导入过的模型会收进 `Saved Assets`,默认折叠。
- 右侧 `Asset Source` 用来选择生成模式或导入本地 `.glb` / `.gltf`
- 左侧 `Generation Queue` 可以查看上传、生成、导入状态,并对失败任务重试。
- 需要部件说明时,再点击 `Info``Inspect` 打开详情抽屉。
- 顶部打开 `Library` 可以查看完整资产库:预览、Provider 状态、任务 ID、GLB URL 复制、Provider 对比和删除。
- 顶部点击 `Demo` 进入纯展示模式,适合截图、录屏、演示。
- 录屏前先看主舞台的质量评分;分数低通常说明源图或生成结果还不适合演示。
- Demo 动画会根据模型名称和元数据切换:汽车走低机位推进,飞机走飞行掠过,航母 / 船走侧向巡航,有机 / 标本类资产走工作室环绕。
常用验证命令:
```bash
npm run lint
npm run build
npm run test
npm run test:visual
```
`npm run test:visual` 会运行 Playwright 布局和截图回归,覆盖工作台、Model Library 抽屉和 Demo Mode。只有确认 UI 改动是预期变化时,才运行 `npm run test:visual:update` 更新截图基线。
## 可选 Image-to-3D 后端
创建 `.env.local`
```bash
cp .env.example .env.local
```
然后设置:
```bash
TRIPO_API_KEY=your_tripo_key
FAL_API_KEY=your_fal_key
RODIN_API_KEY=your_rodin_api_key
OPENAI_API_KEY=your_openai_key
API_HOST=127.0.0.1
```
`OPENAI_API_KEY` 会启用可选的图片理解接口 `/api/3d/analyze`。配置后,上传图片会先被视觉模型识别为资产类型、材质重点、检查重点、展示场景、标签,并生成更适合 image-to-3D 的提示词。没配置时,应用继续使用本地文件名和元数据规则,不会影响基础上传和生成。
如需启用 Hunyuan3D 本地备用模式,先启动你的 Hunyuan3D API 服务,再设置:
```bash
HUNYUAN_API_BASE=http://127.0.0.1:8081
HUNYUAN_CREATE_PATH=/send
HUNYUAN_STATUS_PATH=/status
```
3D 生成后端支持这些路径:
```text
Hyper3D 只走 Hyper3D Rodin 云端生成,默认模式
Tripo 只走 Tripo 云端生成
Fal 只走 Fal.ai 队列生成,具体模型在 Settings 里选择
Auto 先 Hyper3D,再 Tripo、Fal、Hunyuan,最后 JS Depth 兜底
Hunyuan 只走本地 Hunyuan3D
```
上传面板支持这些模式:
```text
Hyper3D Hyper3D Rodin GLB 生成
Tripo Tripo 云端 GLB 生成
Fal Fal.ai 队列 GLB 生成
Hunyuan 本地 Hunyuan3D GLB 生成
JS Depth 浏览器侧图片深度浮雕,WebGL 不可用时降级到透明 PNG 分层
Auto Hyper3D -> Tripo -> Fal -> Hunyuan -> JS Depth 依次降级
Local GLB 导入已有 .glb 或自包含 .gltf
```
Tripo 上传使用当前 STS 对象存储流程,然后创建 `image_to_model` 任务。生成后的 GLB 会被 Node 后端缓存到 `.generated-models/`,后续展示优先使用本地副本。
Fal 上传使用官方 `@fal-ai/client` 的 storage 和 queue API。当前支持 Hunyuan3D v2、TRELLIS、TripoSR、Tripo3D v2.5 和 Hyper3D Rodin,具体 Fal 模型在 `Settings` 里选择。
Rodin 上传使用 Hyper3D 的 multipart `/rodin` 任务接口,然后轮询 `/status` 并通过 `/download` 下载和缓存 GLB。
前端模型库会保存到 IndexedDB,所以生成或导入成功的模型记录刷新后仍会恢复。
也可以从 `New Upload` 入口导入本地 `.glb` 或自包含 `.gltf`,导入后会成为自定义工作区模型。
Hunyuan3D 本地 API 预期形式:
```text
POST /send
GET /status/:uid
```
状态接口可以返回远程模型 URL,也可以返回 `model_base64` / `glb_base64` 这类 base64 GLB 字段。base64 GLB 会被缓存到 `.generated-models/` 并由 Node 后端提供访问。
启动后端:
```bash
npm run dev:api
```
启动前端:
```bash
npm run dev
```
默认情况下,前端会访问本地 Node 后端 `http://127.0.0.1:8787`
## Demo 模型
仓库内置了一些缓存 GLB
```text
public/generated-models/
```
这些模型可以让项目在不消耗 API credits 的情况下直接用于演示。
## 参考模型
Library 面板内置了远程 Khronos glTF Sample Models 作为辅助参考,用于检查材质和 GLB 加载:
- Transmission TestCC0Adobe via Khronos。
- Transmission Roughness TestCC-BY 4.0Ed Mackey / Analytical Graphics via Khronos。
- Mosquito In AmberCC-BY 4.0Loic Norgeot / Geoffrey Marchal / Sketchfab via Khronos。
这些模型从 Khronos 已归档样例仓库远程加载,不打包进本仓库。
## 安全
不要把真实 API Key 写进前端代码。密钥只放在 `.env.local`,该文件已被 git 忽略。
## License
MIT
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+28
View File
@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['src/**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
{
files: ['*.js', '*.mjs', 'server/**/*.mjs', 'test/**/*.mjs'],
extends: [js.configs.recommended],
languageOptions: {
globals: globals.node,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3D Model Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+3259
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "model-studio-3d",
"private": false,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev:api": "node server.mjs",
"build": "vite build",
"lint": "eslint .",
"test": "node --test test/*.test.mjs",
"test:visual": "NO_PROXY=localhost,127.0.0.1,::1 no_proxy=localhost,127.0.0.1,::1 playwright test",
"test:visual:update": "NO_PROXY=localhost,127.0.0.1,::1 no_proxy=localhost,127.0.0.1,::1 playwright test --update-snapshots",
"preview": "vite preview"
},
"dependencies": {
"@fal-ai/client": "^1.10.1",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"@react-three/postprocessing": "^3.0.4",
"framer-motion": "^12.38.0",
"lucide-react": "^1.14.0",
"postprocessing": "^6.39.1",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"three": "^0.184.0",
"undici": "^8.2.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"vite": "^8.0.10"
}
}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './test/visual',
timeout: 60_000,
expect: {
timeout: 10_000,
toHaveScreenshot: {
maxDiffPixelRatio: 0.025,
threshold: 0.18,
},
},
snapshotPathTemplate: '{testDir}/__screenshots__/{arg}{ext}',
use: {
baseURL: 'http://127.0.0.1:4173',
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
colorScheme: 'light',
},
webServer: {
command: 'npm run dev -- --host 127.0.0.1 --port 4173',
url: 'http://127.0.0.1:4173',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+208
View File
@@ -0,0 +1,208 @@
import http from 'node:http'
import { API_HOST, API_PORT, FAL_API_KEY, HUNYUAN_API_BASE, RODIN_API_KEY, TRIPO_API_KEY } from './server/config.mjs'
import { assertLocalDiagnosticsRequest, readJsonBody, sendJson, setCorsHeaders } from './server/http-utils.mjs'
import { createRequestId, logEvent, readRecentLogs, summarizeError, summarizePayload } from './server/logger.mjs'
import { importLocalModel, proxyModel, serveLocalModel } from './server/model-store.mjs'
import { createFalTask, getFalHealth, getFalTask } from './server/providers/fal.mjs'
import { createHunyuanTask, getHunyuanHealth, getHunyuanTask } from './server/providers/hunyuan.mjs'
import { createRodinTask, getRodinHealth, getRodinTask } from './server/providers/rodin.mjs'
import { createTripoTask, getTripoHealth, getTripoTask } from './server/providers/tripo.mjs'
import { analyzeAssetImage, getVisionHealth } from './server/providers/vision.mjs'
const DEFAULT_GENERATION_PROVIDER = 'rodin'
const server = http.createServer(async (request, response) => {
const requestId = createRequestId()
const startedAt = Date.now()
let url = null
try {
setCorsHeaders(response)
response.setHeader('X-Request-Id', requestId)
if (request.method === 'OPTIONS') {
response.writeHead(204)
response.end()
return
}
url = new URL(request.url, `http://${request.headers.host}`)
await logEvent('info', 'http.request', {
requestId,
method: request.method,
path: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
})
if (request.method === 'GET' && url.pathname === '/api/3d/health') {
const payload = {
ok: true,
providers: {
tripo: getTripoHealth(),
rodin: getRodinHealth(),
hunyuan: getHunyuanHealth(),
fal: getFalHealth(),
vision: getVisionHealth(),
},
}
sendJson(response, 200, payload)
await logEvent('info', 'http.response', { requestId, path: url.pathname, status: 200, durationMs: Date.now() - startedAt })
return
}
if (request.method === 'GET' && url.pathname === '/api/3d/logs') {
assertLocalDiagnosticsRequest(request)
const payload = await readRecentLogs(url.searchParams.get('limit') || 100)
sendJson(response, 200, payload)
await logEvent('info', 'http.response', { requestId, path: url.pathname, status: 200, durationMs: Date.now() - startedAt, entries: payload.entries.length })
return
}
if (request.method === 'POST' && url.pathname === '/api/3d/analyze') {
const payload = await readJsonBody(request)
await logEvent('info', 'asset.analyze.start', {
requestId,
payload: summarizePayload(payload),
})
const insight = await analyzeAssetImage(payload)
sendJson(response, 200, insight)
await logEvent('info', 'asset.analyze.success', {
requestId,
provider: insight.provider,
configured: insight.configured,
status: insight.status,
categoryId: insight.categoryId,
durationMs: Date.now() - startedAt,
})
return
}
if (request.method === 'POST' && url.pathname === '/api/3d/generate') {
const payload = await readJsonBody(request)
const provider = payload.provider || DEFAULT_GENERATION_PROVIDER
await logEvent('info', 'generation.create.start', {
requestId,
provider,
payload: summarizePayload(payload),
})
const task = await createGenerationTask(provider, payload)
sendJson(response, 200, task)
await logEvent('info', 'generation.create.success', {
requestId,
provider,
taskId: task.taskId,
status: task.status,
durationMs: Date.now() - startedAt,
})
return
}
if (request.method === 'GET' && url.pathname.startsWith('/api/3d/status/')) {
const taskId = decodeURIComponent(url.pathname.replace('/api/3d/status/', ''))
const provider = url.searchParams.get('provider') || DEFAULT_GENERATION_PROVIDER
const task = await getGenerationTask(provider, taskId)
sendJson(response, 200, task)
await logEvent('info', 'generation.status', {
requestId,
provider,
taskId,
status: task.status,
progress: task.progress,
hasModelUrl: Boolean(task.modelUrl),
error: task.error,
durationMs: Date.now() - startedAt,
})
return
}
if (request.method === 'GET' && url.pathname === '/api/3d/model') {
await proxyModel(url, response)
await logEvent('info', 'model.proxy.success', { requestId, durationMs: Date.now() - startedAt })
return
}
if (request.method === 'POST' && url.pathname === '/api/3d/local-model') {
const model = await importLocalModel(request, url)
sendJson(response, 200, model)
await logEvent('info', 'model.import.success', {
requestId,
taskId: model.taskId,
modelUrl: model.modelUrl,
fileName: model.fileName,
durationMs: Date.now() - startedAt,
})
return
}
if (request.method === 'GET' && url.pathname.startsWith('/api/3d/local-model/')) {
await serveLocalModel(url, response)
await logEvent('info', 'model.local.success', { requestId, path: url.pathname, durationMs: Date.now() - startedAt })
return
}
sendJson(response, 404, { error: 'Not found' })
await logEvent('warn', 'http.not_found', { requestId, path: url.pathname, durationMs: Date.now() - startedAt })
} catch (error) {
if (response.headersSent) {
await logEvent('error', 'http.stream_error', {
requestId,
path: url?.pathname,
durationMs: Date.now() - startedAt,
error: summarizeError(error),
})
response.destroy(error)
return
}
const status = error.status || 500
sendJson(response, status, {
error: error.message || 'Server error',
detail: error.detail,
})
await logEvent(status >= 500 ? 'error' : 'warn', 'http.error', {
requestId,
method: request.method,
path: url?.pathname,
status,
durationMs: Date.now() - startedAt,
error: summarizeError(error),
})
}
})
server.listen(API_PORT, API_HOST, () => {
console.log(`Bio demo API running at http://${API_HOST}:${API_PORT}`)
console.log(TRIPO_API_KEY ? 'Tripo API key loaded from environment.' : 'TRIPO_API_KEY is missing. Add it to .env.local.')
console.log(RODIN_API_KEY ? 'Rodin API key loaded from environment.' : 'RODIN_API_KEY is missing. Add it to .env.local.')
console.log(FAL_API_KEY ? 'Fal API key loaded from environment.' : 'FAL_API_KEY is missing. Add it to .env.local.')
console.log(getVisionHealth().configured ? 'Vision analysis provider configured.' : 'Vision analysis is not configured. Add OPENAI_API_KEY to .env.local.')
console.log(`Hunyuan3D local provider: ${HUNYUAN_API_BASE}`)
logEvent('info', 'api.start', {
host: API_HOST,
port: API_PORT,
providers: {
tripo: Boolean(TRIPO_API_KEY),
rodin: Boolean(RODIN_API_KEY),
fal: Boolean(FAL_API_KEY),
hunyuan: Boolean(HUNYUAN_API_BASE),
vision: getVisionHealth().configured,
},
})
})
function createGenerationTask(provider, payload) {
if (provider === 'hunyuan') return createHunyuanTask(payload)
if (provider === 'fal') return createFalTask(payload)
if (provider === 'tripo') return createTripoTask(payload)
return createRodinTask(payload)
}
function getGenerationTask(provider, taskId) {
if (provider === 'hunyuan') return getHunyuanTask(taskId)
if (provider === 'fal') return getFalTask(taskId)
if (provider === 'tripo') return getTripoTask(taskId)
return getRodinTask(taskId)
}
+61
View File
@@ -0,0 +1,61 @@
import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { ProxyAgent } from 'undici'
loadLocalEnv()
export const API_PORT = Number(process.env.API_PORT || 8787)
export const API_HOST = process.env.API_HOST || '127.0.0.1'
export const BODY_LIMIT = 28 * 1024 * 1024
export const MODEL_UPLOAD_LIMIT = 180 * 1024 * 1024
export const TRIPO_API_KEY = process.env.TRIPO_API_KEY
export const TRIPO_API_BASE = process.env.TRIPO_API_BASE || 'https://api.tripo3d.ai/v2/openapi'
export const TRIPO_MODEL_VERSION = process.env.TRIPO_MODEL_VERSION || 'v3.0-20250812'
export const RODIN_API_KEY = process.env.RODIN_API_KEY
export const RODIN_API_BASE = process.env.RODIN_API_BASE || 'https://api.hyper3d.com/api/v2'
export const RODIN_TIER = process.env.RODIN_TIER || 'Gen-2'
export const RODIN_QUALITY = process.env.RODIN_QUALITY || 'medium'
export const RODIN_MESH_MODE = process.env.RODIN_MESH_MODE || 'Raw'
export const RODIN_MATERIAL = process.env.RODIN_MATERIAL || 'PBR'
export const HUNYUAN_API_BASE = process.env.HUNYUAN_API_BASE || 'http://127.0.0.1:8081'
export const HUNYUAN_CREATE_PATH = process.env.HUNYUAN_CREATE_PATH || '/send'
export const HUNYUAN_STATUS_PATH = process.env.HUNYUAN_STATUS_PATH || '/status'
export const FAL_API_KEY = process.env.FAL_API_KEY || process.env.FAL_KEY
export const FAL_DEFAULT_MODEL = process.env.FAL_DEFAULT_MODEL || 'fal-ai/hunyuan3d/v2'
export const VISION_PROVIDER = process.env.VISION_PROVIDER || 'openai'
export const OPENAI_API_KEY = process.env.OPENAI_API_KEY
export const OPENAI_API_BASE = process.env.OPENAI_API_BASE || 'https://api.openai.com/v1'
export const OPENAI_VISION_MODEL = process.env.OPENAI_VISION_MODEL || 'gpt-4o-mini'
export const LOCAL_MODEL_DIR = path.resolve(process.env.LOCAL_MODEL_DIR || '.generated-models')
export const LOG_DIR = path.resolve(process.env.LOG_DIR || '.logs')
export const LOG_FILE = path.resolve(LOG_DIR, process.env.LOG_FILE || '3d-model-studio-api.log')
export const OUTBOUND_PROXY_AGENT = createProxyAgent()
export function hasOutboundProxy() {
return Boolean(process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy)
}
function loadLocalEnv() {
if (!existsSync('.env.local')) return
const env = readFileSync('.env.local', 'utf8')
for (const line of env.split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const index = trimmed.indexOf('=')
if (index === -1) continue
const key = trimmed.slice(0, index).trim()
let value = trimmed.slice(index + 1).trim()
value = value.replace(/^["']|["']$/g, '')
if (!process.env[key]) process.env[key] = value
}
}
function createProxyAgent() {
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy
if (!proxy) return null
return new ProxyAgent(proxy)
}
+124
View File
@@ -0,0 +1,124 @@
import { BODY_LIMIT, MODEL_UPLOAD_LIMIT } from './config.mjs'
export function setCorsHeaders(response) {
response.setHeader('Access-Control-Allow-Origin', process.env.CORS_ORIGIN || '*')
response.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS')
response.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
}
export function assertLocalDiagnosticsRequest(request) {
const remoteAddress = normalizeAddress(request.socket?.remoteAddress)
const origin = request.headers.origin
const referer = request.headers.referer
if (!isLocalHost(remoteAddress)) {
throw Object.assign(new Error('Diagnostics logs are only available from this machine.'), { status: 403 })
}
if (origin && !isLocalUrl(origin)) {
throw Object.assign(new Error('Diagnostics logs are only available to localhost pages.'), { status: 403 })
}
if (!origin && referer && !isLocalUrl(referer)) {
throw Object.assign(new Error('Diagnostics logs are only available to localhost pages.'), { status: 403 })
}
}
export function sendJson(response, status, payload) {
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
response.end(JSON.stringify(payload))
}
export function readJsonBody(request) {
return new Promise((resolve, reject) => {
const chunks = []
let size = 0
request.on('data', (chunk) => {
size += chunk.length
if (size > BODY_LIMIT) {
reject(Object.assign(new Error('Image payload is too large.'), { status: 413 }))
request.destroy()
return
}
chunks.push(chunk)
})
request.on('end', () => {
try {
const raw = Buffer.concat(chunks).toString('utf8')
resolve(raw ? JSON.parse(raw) : {})
} catch {
reject(Object.assign(new Error('Invalid JSON payload.'), { status: 400 }))
}
})
request.on('error', reject)
})
}
export function readRawBody(request, limit = MODEL_UPLOAD_LIMIT) {
return new Promise((resolve, reject) => {
const chunks = []
let size = 0
request.on('data', (chunk) => {
size += chunk.length
if (size > limit) {
reject(Object.assign(new Error('Model payload is too large.'), { status: 413 }))
request.destroy()
return
}
chunks.push(chunk)
})
request.on('end', () => {
resolve(Buffer.concat(chunks))
})
request.on('error', reject)
})
}
export function parseDataUrl(dataUrl) {
if (typeof dataUrl !== 'string') {
throw Object.assign(new Error('imageDataUrl is required.'), { status: 400 })
}
const match = dataUrl.match(/^data:(image\/(?:png|jpe?g|webp));base64,(.+)$/)
if (!match) {
throw Object.assign(new Error('Only PNG, JPEG, or WebP image data URLs are supported.'), { status: 400 })
}
const mime = match[1]
const buffer = Buffer.from(match[2], 'base64')
const ext = mime.includes('png') ? 'png' : mime.includes('webp') ? 'webp' : 'jpg'
if (buffer.length < 1024) {
throw Object.assign(new Error('Image is too small for 3D generation.'), { status: 400 })
}
return { mime, buffer, ext }
}
export function sanitizeFileName(fileName) {
const baseName = String(fileName).split(/[\\/]/).pop() || ''
return baseName.replace(/[^\w.\- ]+/g, '').replace(/^\.+/, '').trim() || 'asset-reference.png'
}
function normalizeAddress(address = '') {
return String(address).replace(/^::ffff:/, '')
}
function isLocalHost(hostname = '') {
const normalized = String(hostname).toLowerCase()
return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1'
}
function isLocalUrl(value) {
try {
return isLocalHost(new URL(value).hostname)
} catch {
return false
}
}
+121
View File
@@ -0,0 +1,121 @@
import { appendFile, mkdir, readFile, stat } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import path from 'node:path'
import { LOG_DIR, LOG_FILE } from './config.mjs'
const MAX_LOG_READ_BYTES = 768 * 1024
const SENSITIVE_KEYS = new Set([
'authorization',
'cookie',
'imageDataUrl',
'modelBase64',
'TRIPO_API_KEY',
'RODIN_API_KEY',
'FAL_API_KEY',
'OPENAI_API_KEY',
])
export function createRequestId() {
return randomUUID().slice(0, 12)
}
export async function logEvent(level, event, fields = {}) {
const entry = {
ts: new Date().toISOString(),
level,
event,
...sanitizeLogValue(fields),
}
try {
await mkdir(LOG_DIR, { recursive: true })
await appendFile(LOG_FILE, `${JSON.stringify(entry)}\n`, 'utf8')
} catch (error) {
console.warn('log write failed', error)
}
return entry
}
export async function readRecentLogs(limit = 100) {
try {
const fileStat = await stat(LOG_FILE)
const content = await readFile(LOG_FILE, 'utf8')
const slice = content.length > MAX_LOG_READ_BYTES ? content.slice(-MAX_LOG_READ_BYTES) : content
const lines = slice.trim().split(/\r?\n/).filter(Boolean)
const entries = lines.slice(-normalizeLimit(limit)).map(parseLogLine).filter(Boolean)
return {
file: path.relative(process.cwd(), LOG_FILE),
size: fileStat.size,
entries,
}
} catch {
return {
file: path.relative(process.cwd(), LOG_FILE),
size: 0,
entries: [],
}
}
}
export function summarizePayload(payload = {}) {
return {
provider: payload.provider,
modelId: payload.modelId,
fileName: payload.fileName,
hasImage: typeof payload.imageDataUrl === 'string',
imageBytes: estimateDataUrlBytes(payload.imageDataUrl),
promptChars: typeof payload.prompt === 'string' ? payload.prompt.length : 0,
}
}
export function summarizeError(error) {
if (!error) return {}
return {
message: error.message || 'Unknown error',
status: error.status,
detail: sanitizeLogValue(error.detail),
}
}
function normalizeLimit(limit) {
const value = Number(limit)
if (!Number.isFinite(value)) return 100
return Math.max(1, Math.min(500, Math.round(value)))
}
function parseLogLine(line) {
try {
return JSON.parse(line)
} catch {
return null
}
}
function estimateDataUrlBytes(value) {
if (typeof value !== 'string') return 0
const comma = value.indexOf(',')
const base64 = comma === -1 ? value : value.slice(comma + 1)
return Math.round((base64.length * 3) / 4)
}
function sanitizeLogValue(value, key = '') {
if (value === null || value === undefined) return value
if (SENSITIVE_KEYS.has(key)) return '[redacted]'
if (typeof value === 'string') {
if (value.startsWith('data:image/')) return `[image-data:${estimateDataUrlBytes(value)} bytes]`
if (value.length > 900) return `${value.slice(0, 900)}...`
return value
}
if (typeof value !== 'object') return value
if (Array.isArray(value)) return value.slice(0, 30).map((item) => sanitizeLogValue(item))
return Object.fromEntries(
Object.entries(value)
.slice(0, 80)
.map(([entryKey, entryValue]) => [entryKey, sanitizeLogValue(entryValue, entryKey)]),
)
}
+210
View File
@@ -0,0 +1,210 @@
import { createWriteStream } from 'node:fs'
import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { fetch as undiciFetch } from 'undici'
import { LOCAL_MODEL_DIR, MODEL_UPLOAD_LIMIT, OUTBOUND_PROXY_AGENT, TRIPO_API_BASE, TRIPO_API_KEY } from './config.mjs'
import { readRawBody, sanitizeFileName } from './http-utils.mjs'
export async function saveLocalModel(taskId, modelData, ext = 'glb') {
const buffer = Buffer.isBuffer(modelData) ? modelData : parseModelBase64(modelData)
validateModelBuffer(buffer, ext)
await mkdir(LOCAL_MODEL_DIR, { recursive: true })
await writeFile(localModelPath(taskId, ext), buffer)
}
export async function hasLocalModel(taskId, ext = 'glb') {
try {
await access(localModelPath(taskId, ext))
return true
} catch {
return false
}
}
export function localModelPath(taskId, ext = 'glb') {
return path.join(LOCAL_MODEL_DIR, `${sanitizeModelId(taskId)}.${ext}`)
}
export function localModelUrl(taskId, ext = 'glb') {
return `/api/3d/local-model/${encodeURIComponent(sanitizeModelId(taskId))}.${ext}`
}
export async function serveLocalModel(url, response) {
const rawFileName = decodeURIComponent(url.pathname.replace('/api/3d/local-model/', ''))
const ext = getModelExtension(rawFileName)
const modelId = rawFileName.replace(/\.(?:glb|gltf)$/i, '')
const buffer = await readFile(localModelPath(modelId, ext))
response.writeHead(200, {
'Content-Type': ext === 'gltf' ? 'model/gltf+json' : 'model/gltf-binary',
'Cache-Control': 'private, max-age=3600',
})
response.end(buffer)
}
export async function importLocalModel(request, url) {
const fileName = sanitizeFileName(url.searchParams.get('fileName') || 'local-model.glb')
const ext = getModelExtension(fileName)
const buffer = await readRawBody(request, MODEL_UPLOAD_LIMIT)
validateModelBuffer(buffer, ext)
const baseName = fileName.replace(/\.(?:glb|gltf)$/i, '') || 'local-model'
const modelId = `local-${Date.now()}-${baseName}`
await saveLocalModel(modelId, buffer, ext)
return {
provider: 'local',
taskId: sanitizeModelId(modelId),
status: 'success',
progress: 100,
modelUrl: localModelUrl(modelId, ext),
rawModelUrl: '',
fileName,
}
}
export async function cacheRemoteModel(taskId, rawModelUrl) {
return cacheRemoteModelAs(taskId, rawModelUrl, getModelExtension(rawModelUrl))
}
export async function cacheRemoteModelAs(taskId, rawModelUrl, ext = 'glb') {
if (await hasLocalModel(taskId, ext)) return localModelUrl(taskId, ext)
await mkdir(LOCAL_MODEL_DIR, { recursive: true })
const targetPath = localModelPath(taskId, ext)
const tempPath = `${targetPath}.${Date.now()}.tmp`
try {
const remote = await fetchRemoteModel(rawModelUrl)
await pipeline(Readable.fromWeb(remote.body), createWriteStream(tempPath))
const buffer = await readFile(tempPath)
validateModelBuffer(buffer, ext)
await rename(tempPath, targetPath)
} catch (error) {
await rm(tempPath, { force: true }).catch(() => {})
throw error
}
return localModelUrl(taskId, ext)
}
export async function proxyModel(url, response) {
const rawUrl = url.searchParams.get('url')
if (!rawUrl || !isAllowedProxyModelUrl(rawUrl)) {
throw Object.assign(new Error('A valid HTTPS or localhost model URL is required.'), { status: 400 })
}
const fetchOptions = shouldUseProxy(rawUrl) && OUTBOUND_PROXY_AGENT ? { dispatcher: OUTBOUND_PROXY_AGENT } : {}
const remote = await undiciFetch(rawUrl, fetchOptions)
if (!remote.ok || !remote.body) {
const retry = await fetchWithTripoAuth(rawUrl, fetchOptions)
if (!retry.ok || !retry.body) {
throw Object.assign(new Error(`Model download failed with ${retry.status || remote.status}.`), { status: 502 })
}
await streamRemoteModel(retry, response)
return
}
await streamRemoteModel(remote, response)
}
export function getModelExtension(value) {
const pathname = /^https?:\/\//i.test(String(value)) ? new URL(value).pathname : String(value)
const ext = path.extname(pathname).replace('.', '').toLowerCase()
if (ext === 'gltf') return 'gltf'
if (ext === 'glb') return 'glb'
throw Object.assign(new Error('Only GLB or self-contained GLTF models are supported.'), { status: 400 })
}
export function validateModelBuffer(buffer, ext = 'glb') {
if (!Buffer.isBuffer(buffer) || buffer.length < 32) {
throw Object.assign(new Error('Model file is too small or invalid.'), { status: 400 })
}
if (ext === 'glb') {
if (buffer.subarray(0, 4).toString('ascii') !== 'glTF') {
throw Object.assign(new Error('GLB files must start with a glTF binary header.'), { status: 400 })
}
return
}
try {
JSON.parse(buffer.toString('utf8'))
} catch {
throw Object.assign(new Error('GLTF files must be valid JSON.'), { status: 400 })
}
}
export function sanitizeModelId(value) {
return sanitizeFileName(String(value)).replace(/\.(?:glb|gltf)$/i, '').replace(/\s+/g, '-').slice(0, 96) || `model-${Date.now()}`
}
export function shouldUseProxy(rawUrl) {
try {
const parsed = new URL(rawUrl)
return !['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname)
} catch {
return true
}
}
export function shouldAttachTripoAuth(rawUrl) {
if (!TRIPO_API_KEY) return false
try {
const host = new URL(rawUrl).hostname
const tripoHost = new URL(TRIPO_API_BASE).hostname
return host === tripoHost || host.endsWith('.tripo3d.ai')
} catch {
return false
}
}
async function fetchRemoteModel(rawUrl) {
const fetchOptions = shouldUseProxy(rawUrl) && OUTBOUND_PROXY_AGENT ? { dispatcher: OUTBOUND_PROXY_AGENT } : {}
const remote = await undiciFetch(rawUrl, fetchOptions)
if (remote.ok && remote.body) return remote
const retry = await fetchWithTripoAuth(rawUrl, fetchOptions)
if (retry.ok && retry.body) return retry
throw Object.assign(new Error(`Model download failed with ${retry.status || remote.status}.`), { status: 502 })
}
async function fetchWithTripoAuth(rawUrl, fetchOptions) {
if (!shouldAttachTripoAuth(rawUrl)) {
return { ok: false, status: 401, body: null }
}
return undiciFetch(rawUrl, {
headers: { Authorization: `Bearer ${TRIPO_API_KEY}` },
...fetchOptions,
})
}
function parseModelBase64(modelBase64) {
const raw = String(modelBase64 || '').replace(/^data:.*?;base64,/, '')
return Buffer.from(raw, 'base64')
}
function isAllowedProxyModelUrl(rawUrl) {
try {
const parsed = new URL(rawUrl)
if (parsed.protocol === 'https:') return true
if (parsed.protocol !== 'http:') return false
return ['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname)
} catch {
return false
}
}
async function streamRemoteModel(remote, response) {
response.writeHead(200, {
'Content-Type': remote.headers.get('content-type') || 'model/gltf-binary',
'Cache-Control': 'private, max-age=3600',
})
await pipeline(Readable.fromWeb(remote.body), response)
}
+53
View File
@@ -0,0 +1,53 @@
export function findFirstValue(value, keys) {
if (!value || typeof value !== 'object') return ''
for (const key of keys) {
if (typeof value[key] === 'string' && value[key]) return value[key]
}
for (const child of Object.values(value)) {
if (Array.isArray(child)) {
for (const item of child) {
const found = findFirstValue(item, keys)
if (found) return found
}
} else if (child && typeof child === 'object') {
const found = findFirstValue(child, keys)
if (found) return found
}
}
return ''
}
export function findModelUrl(value) {
const urls = []
collectUrls(value, urls)
const glb = urls.find((url) => /\.glb(?:[?#]|$)/i.test(url))
if (glb) return glb
return urls.find((url) => /\.gltf(?:[?#]|$)/i.test(url)) || ''
}
export function isSuccessStatus(status) {
return ['success', 'succeeded', 'completed', 'complete', 'done', 'finish', 'finished'].includes(String(status || '').toLowerCase())
}
function collectUrls(value, urls) {
if (!value) return
if (typeof value === 'string') {
if (/^https?:\/\//i.test(value)) urls.push(value)
return
}
if (Array.isArray(value)) {
value.forEach((item) => collectUrls(item, urls))
return
}
if (typeof value === 'object') {
Object.values(value).forEach((item) => collectUrls(item, urls))
}
}
+293
View File
@@ -0,0 +1,293 @@
import { createFalClient } from '@fal-ai/client'
import { fetch as undiciFetch } from 'undici'
import { FAL_API_KEY, FAL_DEFAULT_MODEL, OUTBOUND_PROXY_AGENT } from '../config.mjs'
import { parseDataUrl } from '../http-utils.mjs'
import { cacheRemoteModelAs, hasLocalModel, localModelUrl } from '../model-store.mjs'
import { isSuccessStatus } from '../object-utils.mjs'
export const FAL_MODEL_DEFINITIONS = [
{
id: 'fal-ai/hunyuan3d/v2',
label: 'Hunyuan3D v2',
imageField: 'input_image_url',
defaults: {},
supportsSeed: true,
},
{
id: 'fal-ai/trellis',
label: 'TRELLIS',
imageField: 'image_url',
defaults: { texture_size: '1024' },
supportsSeed: true,
},
{
id: 'fal-ai/triposr',
label: 'TripoSR',
imageField: 'image_url',
defaults: { do_remove_background: true, output_format: 'glb' },
supportsSeed: false,
},
{
id: 'tripo3d/tripo/v2.5/image-to-3d',
label: 'Tripo3D v2.5',
imageField: 'image_url',
defaults: { orientation: 'align_image', pbr: true, texture: 'standard' },
supportsSeed: true,
},
{
id: 'fal-ai/hyper3d/rodin',
label: 'Hyper3D Rodin',
imageField: 'input_image_urls',
defaults: {
geometry_file_format: 'glb',
material: 'PBR',
quality: 'medium',
tier: 'Regular',
},
supportsPrompt: true,
supportsSeed: true,
},
]
export const FAL_MODEL_IDS = new Set(FAL_MODEL_DEFINITIONS.map((model) => model.id))
const FALLBACK_FAL_MODEL = FAL_MODEL_DEFINITIONS[0].id
let falClient = null
export function getFalHealth() {
return {
configured: Boolean(FAL_API_KEY),
defaultModel: normalizeFalModelId(FAL_DEFAULT_MODEL),
models: FAL_MODEL_DEFINITIONS.map(({ id, label }) => ({ id, label })),
}
}
export async function createFalTask(payload) {
const client = getFalClient()
const modelId = normalizeFalModelId(payload.modelId || payload.falModelId || FAL_DEFAULT_MODEL)
const image = parseDataUrl(payload.imageDataUrl)
const blob = new Blob([image.buffer], { type: image.mime })
const imageUrl = await client.storage.upload(blob, { lifecycle: { expiresIn: '1d' } })
const input = buildFalInput(modelId, imageUrl, payload)
const raw = await client.queue.submit(modelId, { input })
const requestId = raw.request_id || raw.requestId
if (!requestId) {
const error = new Error('Fal task response did not include a request id.')
error.detail = raw
throw error
}
return {
provider: 'fal',
taskId: encodeFalTaskId({ modelId, requestId }),
status: normalizeFalStatus(raw.status),
raw,
}
}
export async function getFalTask(taskId) {
const client = getFalClient()
if (!taskId) {
throw Object.assign(new Error('taskId is required.'), { status: 400 })
}
const task = decodeFalTaskId(taskId)
const cacheId = getFalCacheId(task)
if (await hasLocalModel(cacheId, 'glb')) {
return {
provider: 'fal',
taskId,
status: 'success',
progress: 100,
modelUrl: localModelUrl(cacheId, 'glb'),
rawModelUrl: '',
error: '',
raw: { cached: true },
}
}
const statusRaw = await client.queue.status(task.modelId, { requestId: task.requestId, logs: true })
const status = normalizeFalStatus(statusRaw.status)
let modelUrl = ''
let rawModelUrl = ''
let cacheError = ''
let result = null
if (status === 'success') {
result = await client.queue.result(task.modelId, { requestId: task.requestId })
const modelFile = findFalModelFile(result.data ?? result)
rawModelUrl = modelFile.url
if (rawModelUrl) {
try {
modelUrl = await cacheRemoteModelAs(cacheId, rawModelUrl, modelFile.ext)
} catch (error) {
cacheError = error.message || 'Fal model cache failed.'
modelUrl = `/api/3d/model?url=${encodeURIComponent(rawModelUrl)}`
}
} else {
cacheError = 'Fal response did not include a GLB or GLTF URL.'
}
}
return {
provider: 'fal',
taskId,
status,
progress: getFalProgress(statusRaw, status),
modelUrl,
rawModelUrl,
error: statusRaw.error || cacheError || '',
raw: result?.data ?? result ?? statusRaw,
}
}
export function buildFalInput(modelId, imageUrl, payload = {}) {
const model = getFalModelDefinition(modelId)
const input = { ...model.defaults }
if (model.imageField === 'input_image_urls') {
input.input_image_urls = [imageUrl]
} else {
input[model.imageField] = imageUrl
}
if (model.supportsPrompt && payload.prompt) input.prompt = payload.prompt
if (model.supportsSeed && payload.seed !== undefined && Number.isFinite(Number(payload.seed))) {
input.seed = Number(payload.seed)
}
return input
}
export function encodeFalTaskId(task) {
return `fal-${Buffer.from(JSON.stringify(task)).toString('base64url')}`
}
export function decodeFalTaskId(taskId) {
const raw = String(taskId || '')
if (!raw.startsWith('fal-')) {
return { modelId: normalizeFalModelId(FAL_DEFAULT_MODEL), requestId: raw }
}
try {
const parsed = JSON.parse(Buffer.from(raw.slice(4), 'base64url').toString('utf8'))
return {
modelId: normalizeFalModelId(parsed.modelId || FAL_DEFAULT_MODEL),
requestId: parsed.requestId || parsed.request_id || raw,
}
} catch {
return { modelId: normalizeFalModelId(FAL_DEFAULT_MODEL), requestId: raw }
}
}
export function normalizeFalStatus(value) {
const status = String(value || '').toLowerCase()
if (!status) return 'queued'
if (['in_queue', 'queued', 'pending'].includes(status)) return 'queued'
if (['in_progress', 'running', 'processing'].includes(status)) return 'running'
if (['failed', 'error', 'cancelled', 'canceled'].includes(status)) return 'failed'
if (isSuccessStatus(status)) return 'success'
return status
}
export function normalizeFalModelId(value) {
const modelId = String(value || '').trim().replace(/^\/+|\/+$/g, '')
return FAL_MODEL_IDS.has(modelId) ? modelId : FALLBACK_FAL_MODEL
}
export function findFalModelFile(value) {
const candidates = []
collectFalModelFiles(value, candidates, '')
candidates.sort((a, b) => a.score - b.score)
const candidate = candidates[0]
return candidate ? { url: candidate.url, ext: candidate.ext } : { url: '', ext: 'glb' }
}
function getFalClient() {
requireFalKey()
if (falClient) return falClient
falClient = createFalClient({
credentials: FAL_API_KEY,
fetch: (url, options = {}) => undiciFetch(url, {
...options,
...(OUTBOUND_PROXY_AGENT ? { dispatcher: OUTBOUND_PROXY_AGENT } : {}),
}),
})
return falClient
}
function getFalModelDefinition(modelId) {
const normalized = normalizeFalModelId(modelId)
return FAL_MODEL_DEFINITIONS.find((model) => model.id === normalized) || FAL_MODEL_DEFINITIONS[0]
}
function getFalCacheId(task) {
return `fal-${String(task.requestId || '').replace(/^fal-/, '')}`
}
function getFalProgress(raw, status) {
if (status === 'success') return 100
if (status === 'failed') return null
if (status === 'queued') return Number.isFinite(raw.queue_position) ? 0 : 0
if (typeof raw.progress === 'number') return raw.progress
if (typeof raw.percent === 'number') return raw.percent
return null
}
function requireFalKey() {
if (!FAL_API_KEY) {
const error = new Error('FAL_API_KEY is not configured on the backend.')
error.status = 500
throw error
}
}
function collectFalModelFiles(value, candidates, key) {
if (!value) return
if (typeof value === 'string') {
const ext = inferModelExtension({ url: value })
if (ext) candidates.push({ url: value, ext, score: scoreFalModelCandidate(key, ext) })
return
}
if (Array.isArray(value)) {
value.forEach((item) => collectFalModelFiles(item, candidates, key))
return
}
if (typeof value !== 'object') return
const url = value.url || value.file_url || value.download_url || value.uri || value.href
if (typeof url === 'string' && /^https?:\/\//i.test(url)) {
const ext = inferModelExtension({ url, fileName: value.file_name || value.fileName || value.name, contentType: value.content_type || value.contentType || value.mime_type })
if (ext) candidates.push({ url, ext, score: scoreFalModelCandidate(key, ext) })
}
for (const [childKey, child] of Object.entries(value)) {
collectFalModelFiles(child, candidates, childKey)
}
}
function inferModelExtension({ url, fileName, contentType }) {
const source = `${url || ''} ${fileName || ''}`.toLowerCase()
if (/\.glb(?:[?#\s]|$)/i.test(source)) return 'glb'
if (/\.gltf(?:[?#\s]|$)/i.test(source)) return 'gltf'
const type = String(contentType || '').toLowerCase()
if (type.includes('model/gltf-binary') || type.includes('application/octet-stream')) return 'glb'
if (type.includes('model/gltf+json')) return 'gltf'
return ''
}
function scoreFalModelCandidate(key, ext) {
const name = String(key || '').toLowerCase()
const keyScore = name.includes('pbr') ? 0 : name.includes('glb') ? 1 : name.includes('mesh') ? 2 : name.includes('base') ? 3 : 4
const extScore = ext === 'glb' ? 0 : 1
return keyScore * 10 + extScore
}
+146
View File
@@ -0,0 +1,146 @@
import { fetch as undiciFetch } from 'undici'
import { HUNYUAN_API_BASE, HUNYUAN_CREATE_PATH, HUNYUAN_STATUS_PATH } from '../config.mjs'
import { parseDataUrl } from '../http-utils.mjs'
import { hasLocalModel, localModelUrl, saveLocalModel } from '../model-store.mjs'
import { findFirstValue, findModelUrl } from '../object-utils.mjs'
export function getHunyuanHealth() {
return {
configured: Boolean(HUNYUAN_API_BASE),
baseUrl: HUNYUAN_API_BASE,
createPath: HUNYUAN_CREATE_PATH,
statusPath: HUNYUAN_STATUS_PATH,
}
}
export async function createHunyuanTask(payload) {
const image = parseDataUrl(payload.imageDataUrl)
const imageBase64 = image.buffer.toString('base64')
const requestBody = {
image: `data:${image.mime};base64,${imageBase64}`,
image_base64: imageBase64,
prompt: payload.prompt || '',
seed: payload.seed ?? 1234,
remove_background: payload.removeBackground ?? true,
texture: payload.texture ?? true,
pbr: payload.pbr ?? true,
octree_resolution: payload.octreeResolution ?? 256,
num_inference_steps: payload.numInferenceSteps ?? 50,
guidance_scale: payload.guidanceScale ?? 5.5,
face_count: payload.faceCount ?? 60000,
}
const raw = await hunyuanRequest(HUNYUAN_CREATE_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
})
const data = raw.data || raw
const taskId = findFirstValue(data, ['uid', 'task_id', 'taskId', 'id']) || `hunyuan-${Date.now()}`
const rawModelUrl = findModelUrl(data)
const modelBase64 = findFirstValue(data, ['model_base64', 'modelBase64', 'glb_base64', 'glbBase64'])
let modelUrl = rawModelUrl ? `/api/3d/model?url=${encodeURIComponent(rawModelUrl)}` : ''
if (modelBase64) {
await saveLocalModel(taskId, modelBase64, 'glb')
modelUrl = localModelUrl(taskId, 'glb')
}
return {
provider: 'hunyuan',
taskId,
status: modelUrl ? 'success' : 'queued',
modelUrl,
raw: sanitizeHunyuanRaw(raw),
}
}
export async function getHunyuanTask(taskId) {
if (!taskId) {
throw Object.assign(new Error('taskId is required.'), { status: 400 })
}
if (await hasLocalModel(taskId, 'glb')) {
return {
provider: 'hunyuan',
taskId,
status: 'success',
progress: 100,
modelUrl: localModelUrl(taskId, 'glb'),
rawModelUrl: '',
error: '',
raw: {},
}
}
const raw = await hunyuanRequest(`${HUNYUAN_STATUS_PATH}/${encodeURIComponent(taskId)}`, { method: 'GET' })
const data = raw.data || raw
const status = normalizeHunyuanStatus(data.status || data.task_status || data.state || data.message || 'running')
const progress = data.progress ?? data.percent ?? null
const rawModelUrl = findModelUrl(data)
const modelBase64 = findFirstValue(data, ['model_base64', 'modelBase64', 'glb_base64', 'glbBase64'])
let modelUrl = rawModelUrl ? `/api/3d/model?url=${encodeURIComponent(rawModelUrl)}` : ''
if (modelBase64) {
await saveLocalModel(taskId, modelBase64, 'glb')
modelUrl = localModelUrl(taskId, 'glb')
}
return {
provider: 'hunyuan',
taskId,
status,
progress,
modelUrl,
rawModelUrl,
error: data.error || data.message || '',
raw: sanitizeHunyuanRaw(raw),
}
}
async function hunyuanRequest(requestPath, options = {}) {
let response
try {
response = await undiciFetch(`${HUNYUAN_API_BASE.replace(/\/$/, '')}${requestPath.startsWith('/') ? requestPath : `/${requestPath}`}`, options)
} catch (error) {
const wrapped = new Error(`Hunyuan3D local server unavailable at ${HUNYUAN_API_BASE}. Start the local Hunyuan3D API server or switch provider.`)
wrapped.detail = {
path: requestPath,
cause: error.cause?.message || error.cause?.code || error.message,
}
throw wrapped
}
const text = await response.text()
let data
try {
data = text ? JSON.parse(text) : {}
} catch {
data = { message: text || 'Non-JSON response from Hunyuan3D.' }
}
if (!response.ok || (typeof data.code === 'number' && data.code !== 0)) {
const error = new Error(data.message || data.error || `Hunyuan3D request failed with ${response.status}.`)
error.status = response.status || 502
error.detail = sanitizeHunyuanRaw(data)
throw error
}
return data
}
function normalizeHunyuanStatus(status) {
const value = String(status || '').toLowerCase()
if (['success', 'succeeded', 'completed', 'complete', 'done', 'finish', 'finished'].includes(value)) return 'success'
if (['failed', 'error', 'cancelled', 'canceled'].includes(value)) return 'failed'
if (['queued', 'pending', 'waiting'].includes(value)) return 'queued'
return 'running'
}
function sanitizeHunyuanRaw(raw) {
if (!raw || typeof raw !== 'object') return raw
return JSON.parse(JSON.stringify(raw, (key, value) => {
if (['model_base64', 'modelBase64', 'glb_base64', 'glbBase64'].includes(key)) return '[base64 omitted]'
return value
}))
}
+246
View File
@@ -0,0 +1,246 @@
import { Blob } from 'node:buffer'
import { fetch as undiciFetch, FormData } from 'undici'
import {
OUTBOUND_PROXY_AGENT,
RODIN_API_BASE,
RODIN_API_KEY,
RODIN_MATERIAL,
RODIN_MESH_MODE,
RODIN_QUALITY,
RODIN_TIER,
hasOutboundProxy,
} from '../config.mjs'
import { parseDataUrl, sanitizeFileName } from '../http-utils.mjs'
import { cacheRemoteModelAs, hasLocalModel, localModelUrl } from '../model-store.mjs'
import { findFirstValue } from '../object-utils.mjs'
export function getRodinHealth() {
return {
configured: Boolean(RODIN_API_KEY),
baseUrl: RODIN_API_BASE,
tier: RODIN_TIER,
quality: RODIN_QUALITY,
meshMode: RODIN_MESH_MODE,
material: RODIN_MATERIAL,
}
}
export async function createRodinTask(payload) {
requireRodinKey()
const image = parseDataUrl(payload.imageDataUrl)
const fileName = sanitizeFileName(payload.fileName || `cell-reference.${image.ext}`)
const form = new FormData()
form.append('images', new Blob([image.buffer], { type: image.mime }), fileName)
form.append('geometry_file_format', 'glb')
form.append('material', payload.material || RODIN_MATERIAL)
form.append('quality', payload.quality || RODIN_QUALITY)
form.append('tier', payload.tier || RODIN_TIER)
form.append('mesh_mode', payload.meshMode || RODIN_MESH_MODE)
if (payload.prompt) form.append('prompt', payload.prompt)
if (payload.seed !== undefined) form.append('seed', String(payload.seed))
const raw = await rodinRequest('/rodin', {
method: 'POST',
body: form,
})
const taskUuid = findFirstValue(raw, ['uuid', 'task_uuid', 'taskUuid', 'taskId', 'id'])
const subscriptionKey = findFirstValue(raw.jobs || raw, ['subscription_key', 'subscriptionKey'])
if (!taskUuid) {
const error = new Error('Rodin task response did not include a task uuid.')
error.detail = sanitizeRodinRaw(raw)
throw error
}
if (!subscriptionKey) {
const error = new Error('Rodin task response did not include a subscription key.')
error.detail = sanitizeRodinRaw(raw)
throw error
}
return {
provider: 'rodin',
taskId: encodeRodinTaskId({ taskUuid, subscriptionKey }),
status: 'queued',
raw: sanitizeRodinRaw(raw),
}
}
export async function getRodinTask(taskId) {
requireRodinKey()
if (!taskId) {
throw Object.assign(new Error('taskId is required.'), { status: 400 })
}
const rodinTask = decodeRodinTaskId(taskId)
if (await hasLocalModel(rodinTask.taskUuid, 'glb')) {
return {
provider: 'rodin',
taskId,
status: 'success',
progress: 100,
modelUrl: localModelUrl(rodinTask.taskUuid, 'glb'),
rawModelUrl: '',
error: '',
raw: { cached: true },
}
}
const raw = await rodinRequest('/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription_key: rodinTask.subscriptionKey }),
})
const jobs = Array.isArray(raw.jobs) ? raw.jobs : []
const status = normalizeRodinStatus(jobs.map((job) => job.status).filter(Boolean))
let modelUrl = ''
let rawModelUrl = ''
let cacheError = ''
if (status === 'success') {
try {
const download = await getRodinDownload(rodinTask.taskUuid)
rawModelUrl = download.url
modelUrl = await cacheRemoteModelAs(rodinTask.taskUuid, rawModelUrl, download.ext)
} catch (error) {
cacheError = error.message || 'Rodin model download failed.'
}
}
return {
provider: 'rodin',
taskId,
status,
progress: getRodinProgress(status, jobs),
modelUrl,
rawModelUrl,
error: raw.error || cacheError || '',
raw: sanitizeRodinRaw(raw),
}
}
export function encodeRodinTaskId(task) {
return `rodin-${Buffer.from(JSON.stringify(task)).toString('base64url')}`
}
export function decodeRodinTaskId(taskId) {
const raw = String(taskId || '')
if (!raw.startsWith('rodin-')) {
return { taskUuid: raw, subscriptionKey: raw }
}
try {
const parsed = JSON.parse(Buffer.from(raw.slice(6), 'base64url').toString('utf8'))
return {
taskUuid: parsed.taskUuid || parsed.uuid || raw,
subscriptionKey: parsed.subscriptionKey || parsed.subscription_key || parsed.taskUuid || raw,
}
} catch {
return { taskUuid: raw, subscriptionKey: raw }
}
}
export function normalizeRodinStatus(statuses) {
const values = (Array.isArray(statuses) ? statuses : [statuses]).map((status) => String(status || '').trim().toLowerCase())
if (!values.length) return 'running'
if (values.some((status) => ['failed', 'failure', 'error', 'cancelled', 'canceled'].includes(status))) return 'failed'
if (values.every((status) => ['done', 'success', 'succeeded', 'completed', 'complete', 'finish', 'finished'].includes(status))) return 'success'
if (values.some((status) => ['waiting', 'queued', 'pending'].includes(status))) return 'queued'
return 'running'
}
export function findRodinDownloadItem(raw) {
const items = Array.isArray(raw?.list) ? raw.list : []
return items.find((entry) => /\.glb(?:[?#]|$)/i.test(entry.name || entry.url || ''))
|| items.find((entry) => /\.gltf(?:[?#]|$)/i.test(entry.name || entry.url || ''))
|| items.find((entry) => /^https?:\/\//i.test(entry.url || ''))
|| null
}
function requireRodinKey() {
if (!RODIN_API_KEY) {
const error = new Error('RODIN_API_KEY is not configured on the backend.')
error.status = 500
throw error
}
}
async function getRodinDownload(taskUuid) {
const raw = await rodinRequest('/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_uuid: taskUuid }),
})
const item = findRodinDownloadItem(raw)
if (!item?.url) {
const error = new Error('Rodin download response did not include a model URL.')
error.detail = sanitizeRodinRaw(raw)
throw error
}
const ext = /\.gltf(?:[?#]|$)/i.test(item.name || item.url) ? 'gltf' : 'glb'
return { url: item.url, ext, raw }
}
function getRodinProgress(status, jobs) {
if (status === 'success') return 100
if (status === 'queued') return 0
if (!Array.isArray(jobs) || !jobs.length) return null
const done = jobs.filter((job) => normalizeRodinStatus(job.status) === 'success').length
if (!done) return null
return Math.round((done / jobs.length) * 100)
}
async function rodinRequest(requestPath, options = {}) {
let response
try {
response = await undiciFetch(`${RODIN_API_BASE.replace(/\/$/, '')}${requestPath.startsWith('/') ? requestPath : `/${requestPath}`}`, {
...options,
...(OUTBOUND_PROXY_AGENT ? { dispatcher: OUTBOUND_PROXY_AGENT } : {}),
headers: {
Authorization: `Bearer ${RODIN_API_KEY}`,
Accept: 'application/json',
...(options.headers || {}),
},
})
} catch (error) {
const wrapped = new Error(`Rodin network request failed: ${error.message}`)
wrapped.detail = {
path: requestPath,
cause: error.cause?.message || error.cause?.code || '',
proxy: hasOutboundProxy(),
}
throw wrapped
}
const text = await response.text()
let data
try {
data = text ? JSON.parse(text) : {}
} catch {
data = { message: text || 'Non-JSON response from Rodin.' }
}
if (!response.ok || data.error) {
const error = new Error(data.message || data.error || `Rodin request failed with ${response.status}.`)
error.status = response.status || 502
error.detail = sanitizeRodinRaw(data)
throw error
}
return data
}
function sanitizeRodinRaw(raw) {
if (!raw || typeof raw !== 'object') return raw
return JSON.parse(JSON.stringify(raw, (key, value) => {
if (['subscription_key', 'subscriptionKey'].includes(key)) return '[secret omitted]'
return value
}))
}
+277
View File
@@ -0,0 +1,277 @@
import { createHash, createHmac } from 'node:crypto'
import path from 'node:path'
import { fetch as undiciFetch } from 'undici'
import { OUTBOUND_PROXY_AGENT, TRIPO_API_BASE, TRIPO_API_KEY, TRIPO_MODEL_VERSION, hasOutboundProxy } from '../config.mjs'
import { parseDataUrl, sanitizeFileName } from '../http-utils.mjs'
import { cacheRemoteModel, hasLocalModel, localModelUrl, shouldUseProxy } from '../model-store.mjs'
import { findFirstValue, findModelUrl, isSuccessStatus } from '../object-utils.mjs'
export function getTripoHealth() {
return {
configured: Boolean(TRIPO_API_KEY),
modelVersion: TRIPO_MODEL_VERSION,
}
}
export async function createTripoTask(payload) {
requireTripoKey()
const image = parseDataUrl(payload.imageDataUrl)
const fileName = sanitizeFileName(payload.fileName || `cell-reference.${image.ext}`)
const file = await uploadImageToTripo({ ...image, fileName })
const task = await createTripoImageTask({ file })
return {
provider: 'tripo',
taskId: task.taskId,
raw: task.raw,
}
}
export async function getTripoTask(taskId) {
if (!taskId) {
throw Object.assign(new Error('taskId is required.'), { status: 400 })
}
if (await hasLocalModel(taskId, 'glb')) {
return {
provider: 'tripo',
taskId,
status: 'success',
progress: 100,
modelUrl: localModelUrl(taskId, 'glb'),
rawModelUrl: '',
error: '',
raw: { cached: true },
}
}
requireTripoKey()
const raw = await tripoRequest(`/task/${encodeURIComponent(taskId)}`, { method: 'GET' })
const data = raw.data || raw
const status = data.status || data.task_status || data.state || 'unknown'
const rawModelUrl = findModelUrl(data)
let modelUrl = rawModelUrl ? `/api/3d/model?url=${encodeURIComponent(rawModelUrl)}` : ''
let cacheError = ''
if (rawModelUrl && isSuccessStatus(status)) {
try {
modelUrl = await cacheRemoteModel(taskId, rawModelUrl)
} catch (error) {
cacheError = error.message || 'Model cache failed.'
}
}
return {
provider: 'tripo',
taskId,
status,
progress: data.progress ?? data.percent ?? null,
modelUrl,
rawModelUrl,
error: data.error || cacheError || '',
raw,
}
}
function requireTripoKey() {
if (!TRIPO_API_KEY) {
const error = new Error('TRIPO_API_KEY is not configured on the backend.')
error.status = 500
throw error
}
}
async function uploadImageToTripo({ buffer, mime, fileName }) {
const format = getTripoUploadFormat(fileName, mime)
const tokenResult = await tripoRequest('/upload/sts/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ format }),
})
const tokenData = tokenResult.data || tokenResult
const host = tokenData.s3_host
const bucket = tokenData.resource_bucket
const key = tokenData.resource_uri
if (!host || !bucket || !key || !tokenData.sts_ak || !tokenData.sts_sk || !tokenData.session_token) {
const error = new Error('Tripo STS upload token response is missing required fields.')
error.detail = sanitizeTripoRaw(tokenResult)
throw error
}
await uploadToTripoObjectStorage({
buffer,
mime,
host,
bucket,
key,
accessKeyId: tokenData.sts_ak,
secretAccessKey: tokenData.sts_sk,
sessionToken: tokenData.session_token,
})
return {
type: 'jpg',
object: {
bucket,
key,
},
}
}
async function createTripoImageTask({ file }) {
const payload = {
type: 'image_to_model',
model_version: TRIPO_MODEL_VERSION,
file,
texture: true,
pbr: true,
texture_quality: 'standard',
geometry_quality: 'standard',
enable_image_autofix: true,
}
try {
return normalizeTaskCreateResponse(await tripoRequest('/task', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}))
} catch {
const minimalPayload = {
type: 'image_to_model',
file,
}
return normalizeTaskCreateResponse(await tripoRequest('/task', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(minimalPayload),
}))
}
}
function getTripoUploadFormat(fileName, mime) {
const ext = path.extname(fileName).replace('.', '').toLowerCase()
if (ext === 'png' || mime === 'image/png') return 'png'
if (ext === 'webp' || mime === 'image/webp') return 'webp'
return 'jpeg'
}
async function uploadToTripoObjectStorage({ buffer, mime, host, bucket, key, accessKeyId, secretAccessKey, sessionToken }) {
const region = getAwsRegionFromS3Host(host)
const amzDate = getAwsDate()
const date = amzDate.slice(0, 8)
const payloadHash = sha256Hex(buffer)
const canonicalUri = `/${bucket}/${encodeAwsPath(key)}`
const headers = {
'content-type': mime,
host,
'x-amz-content-sha256': payloadHash,
'x-amz-date': amzDate,
'x-amz-security-token': sessionToken,
}
const signedHeaderNames = Object.keys(headers).sort()
const signedHeaders = signedHeaderNames.join(';')
const canonicalHeaders = signedHeaderNames.map((name) => `${name}:${String(headers[name]).trim()}\n`).join('')
const canonicalRequest = ['PUT', canonicalUri, '', canonicalHeaders, signedHeaders, payloadHash].join('\n')
const credentialScope = `${date}/${region}/s3/aws4_request`
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest)].join('\n')
const signingKey = hmac(hmac(hmac(hmac(`AWS4${secretAccessKey}`, date), region), 's3'), 'aws4_request')
const signature = createHmac('sha256', signingKey).update(stringToSign).digest('hex')
const fetchOptions = shouldUseProxy(`https://${host}`) && OUTBOUND_PROXY_AGENT ? { dispatcher: OUTBOUND_PROXY_AGENT } : {}
const response = await undiciFetch(`https://${host}${canonicalUri}`, {
method: 'PUT',
...fetchOptions,
headers: {
...headers,
authorization: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
},
body: buffer,
})
if (!response.ok) {
const detail = await response.text().catch(() => '')
const error = new Error(`Tripo object upload failed with ${response.status}.`)
error.status = response.status || 502
error.detail = detail.slice(0, 500)
throw error
}
}
function normalizeTaskCreateResponse(raw) {
const taskId = findFirstValue(raw, ['task_id', 'taskId', 'id'])
if (!taskId) {
const error = new Error('Tripo task response did not include a task id.')
error.detail = raw
throw error
}
return { taskId, raw }
}
async function tripoRequest(requestPath, options = {}) {
let response
try {
response = await undiciFetch(`${TRIPO_API_BASE}${requestPath}`, {
...options,
...(OUTBOUND_PROXY_AGENT ? { dispatcher: OUTBOUND_PROXY_AGENT } : {}),
headers: {
Authorization: `Bearer ${TRIPO_API_KEY}`,
...(options.headers || {}),
},
})
} catch (error) {
const wrapped = new Error(`Tripo network request failed: ${error.message}`)
wrapped.detail = {
path: requestPath,
cause: error.cause?.message || error.cause?.code || '',
proxy: hasOutboundProxy(),
}
throw wrapped
}
const text = await response.text()
let data
try {
data = text ? JSON.parse(text) : {}
} catch {
data = { message: text || 'Non-JSON response from Tripo.' }
}
if (!response.ok || (typeof data.code === 'number' && data.code !== 0)) {
const error = new Error(data.message || data.error || `Tripo request failed with ${response.status}.`)
error.status = response.status || 502
error.detail = data
throw error
}
return data
}
function sanitizeTripoRaw(raw) {
if (!raw || typeof raw !== 'object') return raw
return JSON.parse(JSON.stringify(raw, (key, value) => {
if (['sts_ak', 'sts_sk', 'session_token'].includes(key)) return '[secret omitted]'
return value
}))
}
function getAwsRegionFromS3Host(host) {
return host.match(/s3[.-]([a-z0-9-]+)\./)?.[1] || 'us-west-2'
}
function getAwsDate(date = new Date()) {
return date.toISOString().replace(/[:-]|\.\d{3}/g, '')
}
function encodeAwsPath(value) {
return String(value).split('/').map((part) => encodeURIComponent(part)).join('/')
}
function sha256Hex(value) {
return createHash('sha256').update(value).digest('hex')
}
function hmac(key, value) {
return createHmac('sha256', key).update(value).digest()
}
+225
View File
@@ -0,0 +1,225 @@
import { fetch as undiciFetch } from 'undici'
import {
OPENAI_API_BASE,
OPENAI_API_KEY,
OPENAI_VISION_MODEL,
OUTBOUND_PROXY_AGENT,
VISION_PROVIDER,
hasOutboundProxy,
} from '../config.mjs'
import { parseDataUrl, sanitizeFileName } from '../http-utils.mjs'
const CATEGORY_IDS = new Set(['artifact', 'road', 'vessel', 'aircraft', 'product', 'specimen'])
const CATEGORY_LABELS = {
artifact: 'Museum Artifact',
road: 'Performance Vehicle',
vessel: 'Naval Vessel',
aircraft: 'Aircraft',
product: 'Product Object',
specimen: 'Organic Specimen',
}
export function getVisionHealth() {
return {
provider: VISION_PROVIDER,
configured: VISION_PROVIDER === 'openai' && Boolean(OPENAI_API_KEY),
model: VISION_PROVIDER === 'openai' ? OPENAI_VISION_MODEL : '',
baseUrl: VISION_PROVIDER === 'openai' ? OPENAI_API_BASE : '',
}
}
export async function analyzeAssetImage(payload = {}) {
const image = parseDataUrl(payload.imageDataUrl)
const fileName = sanitizeFileName(payload.fileName || `asset-reference.${image.ext}`)
if (VISION_PROVIDER !== 'openai') {
return unavailableInsight(fileName, `VISION_PROVIDER=${VISION_PROVIDER} is not supported yet.`)
}
if (!OPENAI_API_KEY) {
return unavailableInsight(fileName, 'OPENAI_API_KEY is not configured on the backend.')
}
const raw = await openAiVisionRequest(payload.imageDataUrl, fileName)
const content = raw?.choices?.[0]?.message?.content || ''
const parsed = extractJsonObject(content)
return normalizeVisionInsight(parsed, {
fileName,
provider: 'openai',
model: OPENAI_VISION_MODEL,
raw,
})
}
export function normalizeVisionInsight(raw = {}, context = {}) {
const categoryId = normalizeCategoryId(raw.categoryId || raw.category || raw.type)
const objectName = cleanText(raw.objectName || raw.name || raw.title || context.fileName || 'Uploaded asset', 90)
const tags = normalizeTags(raw.tags)
return {
provider: context.provider || 'openai',
model: context.model || '',
configured: true,
status: 'success',
objectName,
categoryId,
categoryLabel: cleanText(raw.categoryLabel || CATEGORY_LABELS[categoryId], 48),
description: cleanText(raw.description || raw.summary, 420),
material: cleanText(raw.material || raw.materials, 220),
inspectionFocus: cleanText(raw.inspectionFocus || raw.structureFocus || raw.focus, 220),
presentation: cleanText(raw.presentation || raw.demo || raw.scene, 320),
generationPrompt: cleanText(raw.generationPrompt || raw.prompt, 520),
tags,
confidence: normalizeConfidence(raw.confidence),
reason: cleanText(raw.reason || raw.rationale, 260),
analyzedAt: new Date().toISOString(),
}
}
export function extractJsonObject(content) {
if (!content || typeof content !== 'string') {
throw new Error('Vision model did not return text content.')
}
try {
return JSON.parse(content)
} catch {
const match = content.match(/\{[\s\S]*\}/)
if (!match) throw new Error('Vision model did not return a JSON object.')
return JSON.parse(match[0])
}
}
async function openAiVisionRequest(imageDataUrl, fileName) {
let response
try {
response = await undiciFetch(`${OPENAI_API_BASE.replace(/\/$/, '')}/chat/completions`, {
method: 'POST',
...(OUTBOUND_PROXY_AGENT ? { dispatcher: OUTBOUND_PROXY_AGENT } : {}),
headers: {
Authorization: `Bearer ${OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: OPENAI_VISION_MODEL,
response_format: { type: 'json_object' },
temperature: 0.2,
messages: [
{
role: 'system',
content: [
'You analyze a reference image for a 3D model studio.',
'Return only a compact JSON object.',
'Allowed categoryId values: artifact, road, vessel, aircraft, product, specimen.',
'Choose vessel for aircraft carriers, warships, ships, or submarines, even if the word aircraft appears.',
'Choose artifact for museum relics, bronze objects, masks, statues, ancient objects, or archaeological items.',
'Describe what matters for making and presenting the 3D asset, not generic biology unless it is truly biological.',
].join(' '),
},
{
role: 'user',
content: [
{
type: 'text',
text: [
`File name: ${fileName}`,
'Return JSON with these keys:',
'objectName, categoryId, categoryLabel, description, material, inspectionFocus, presentation, generationPrompt, tags, confidence, reason.',
'Keep objectName short and human-readable.',
'generationPrompt should help an image-to-3D model preserve one integrated object, correct silhouette, materials, and key structure.',
].join(' '),
},
{
type: 'image_url',
image_url: { url: imageDataUrl },
},
],
},
],
}),
})
} catch (error) {
const wrapped = new Error(`OpenAI vision network request failed: ${error.message}`)
wrapped.detail = {
cause: error.cause?.message || error.cause?.code || '',
proxy: hasOutboundProxy(),
}
throw wrapped
}
const text = await response.text()
let data
try {
data = text ? JSON.parse(text) : {}
} catch {
data = { error: { message: text || 'Non-JSON response from OpenAI.' } }
}
if (!response.ok || data.error) {
const error = new Error(data.error?.message || data.message || `OpenAI vision request failed with ${response.status}.`)
error.status = response.status || 502
error.detail = sanitizeOpenAiRaw(data)
throw error
}
return sanitizeOpenAiRaw(data)
}
function unavailableInsight(fileName, message) {
return {
provider: VISION_PROVIDER,
model: VISION_PROVIDER === 'openai' ? OPENAI_VISION_MODEL : '',
configured: false,
status: 'unavailable',
objectName: fileName.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || 'Uploaded asset',
categoryId: '',
categoryLabel: '',
description: '',
material: '',
inspectionFocus: '',
presentation: '',
generationPrompt: '',
tags: [],
confidence: 0,
reason: message,
analyzedAt: new Date().toISOString(),
}
}
function normalizeCategoryId(value) {
const normalized = String(value || '').trim().toLowerCase().replace(/\s+/g, '-')
if (CATEGORY_IDS.has(normalized)) return normalized
if (['car', 'vehicle', 'automobile', 'supercar', 'truck'].includes(normalized)) return 'road'
if (['ship', 'carrier', 'warship', 'naval', 'submarine'].includes(normalized)) return 'vessel'
if (['plane', 'airplane', 'fighter', 'fighter-jet', 'jet'].includes(normalized)) return 'aircraft'
if (['relic', 'museum', 'bronze', 'mask', 'statue'].includes(normalized)) return 'artifact'
if (['cell', 'biology', 'organic', 'organism'].includes(normalized)) return 'specimen'
return 'product'
}
function normalizeTags(tags) {
const rawTags = Array.isArray(tags) ? tags : String(tags || '').split(/[,\n]/)
return [...new Set(rawTags.map((tag) => cleanText(tag, 28).toLowerCase()).filter(Boolean))].slice(0, 8)
}
function normalizeConfidence(value) {
const confidence = Number(value)
if (!Number.isFinite(confidence)) return 0
return Math.max(0, Math.min(1, confidence > 1 ? confidence / 100 : confidence))
}
function cleanText(value, maxLength) {
const text = String(value || '').replace(/\s+/g, ' ').trim()
if (!text) return ''
return text.length > maxLength ? `${text.slice(0, maxLength - 1).trim()}` : text
}
function sanitizeOpenAiRaw(raw) {
if (!raw || typeof raw !== 'object') return raw
return JSON.parse(JSON.stringify(raw, (key, value) => {
if (['authorization', 'api_key', 'apiKey'].includes(String(key).toLowerCase())) return '[secret omitted]'
return value
}))
}
+5486
View File
File diff suppressed because it is too large Load Diff
+1229
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+136
View File
@@ -0,0 +1,136 @@
import { useRef } from 'react'
import { Box, Image, Upload } from 'lucide-react'
import { GENERATION_MODE_OPTIONS } from '../config/appConfig.js'
import { MICROSCOPE_IMAGES } from '../domain/cellData.js'
import { getCell } from '../domain/cellCatalog.js'
import { CellThumb } from './CellThumb.jsx'
export function BottomDeck({
selectedCell,
selectedMicroscope,
setSelectedMicroscope,
uploadedImage,
generationMode,
onGenerationModeChange,
compareCell,
customCells,
latestUploadCell,
onUploadImage,
onCompare,
onOpenGenerationCell,
onNotify,
}) {
const fileInputRef = useRef(null)
const selected = getCell(selectedCell, customCells)
const compareTarget = getCell(compareCell, customCells)
const uploadAccept = generationMode === 'local' ? '.glb,.gltf,model/gltf-binary,model/gltf+json' : 'image/*,.glb,.gltf,model/gltf-binary,model/gltf+json'
function handleMicroscopeSelect(item) {
setSelectedMicroscope(item.label)
onNotify(item.note)
}
return (
<section className="bottom-deck">
<div className="panel media-panel">
<header className="panel-title">
<span>Asset Source</span>
<small>{latestUploadCell ? 5 : 4}</small>
</header>
<div className="generation-mode-row">
<span>Provider</span>
<div className="generation-mode-pills">
{GENERATION_MODE_OPTIONS.map((mode) => (
<button
key={mode.id}
type="button"
className={generationMode === mode.id ? 'active' : ''}
onClick={() => {
onGenerationModeChange(mode.id)
onNotify(`${mode.label} mode selected`)
}}
title={mode.description}
>
{mode.label}
</button>
))}
</div>
</div>
<div className="micro-grid">
{MICROSCOPE_IMAGES.map((item) => (
<button
key={item.label}
type="button"
className={selectedMicroscope === item.label ? `micro-card ${item.tone} active` : `micro-card ${item.tone}`}
onClick={() => handleMicroscopeSelect(item)}
>
<span />
<small>{item.label}</small>
</button>
))}
{latestUploadCell ? (
<>
<button
type="button"
className={uploadedImage ? `add-image active ${uploadedImage.url ? 'with-preview' : 'with-model'}` : 'add-image active with-model'}
style={uploadedImage?.url ? { '--upload-preview': `url(${uploadedImage.url})` } : undefined}
onClick={() => onOpenGenerationCell(latestUploadCell.id)}
title="Open latest uploaded model"
>
{uploadedImage?.url ? <Image size={16} /> : <Box size={16} />}
{latestUploadCell.name || uploadedImage?.name || 'Latest Asset'}
</button>
<button type="button" className="add-image upload-new" onClick={() => fileInputRef.current?.click()} title="Upload a new image or GLB">
<Upload size={16} />
New Upload
</button>
</>
) : (
<button
type="button"
className="add-image"
onClick={() => fileInputRef.current?.click()}
>
<Upload size={16} />
Add Image / GLB
</button>
)}
<input
ref={fileInputRef}
className="hidden-file-input"
type="file"
accept={uploadAccept}
onChange={(event) => {
const file = event.target.files?.[0]
if (!file) return
onUploadImage(file)
event.target.value = ''
}}
/>
</div>
</div>
<div className="panel compare-panel">
<header className="panel-title">
<span>Compare Models</span>
<small>2</small>
</header>
<button type="button" className="compare-box" onClick={() => onCompare(compareTarget.id)}>
<CellThumb cell={selected} selected />
<div>
<strong>{selected.name.replace(' Cell', '')}</strong>
<small>{selected.type}</small>
</div>
<span className="versus">VS</span>
<CellThumb cell={compareTarget} />
<div>
<strong>{compareTarget.name}</strong>
<small>{compareTarget.type.replace('Human ', '')}</small>
</div>
</button>
</div>
</section>
)
}
+12
View File
@@ -0,0 +1,12 @@
export function CellThumb({ cell, selected }) {
const previewUrl = cell.thumbnailUrl || cell.imageUrl || ''
return (
<span
className={`cell-thumb ${cell.custom ? 'custom-cell' : cell.id} ${selected ? 'selected' : ''}`}
style={{ '--cell-accent': cell.accent, '--thumb-image': previewUrl ? `url(${previewUrl})` : undefined }}
>
<span />
</span>
)
}
+422
View File
@@ -0,0 +1,422 @@
import { useEffect, useMemo, useState } from 'react'
import { Box, Camera, CircleDot, Eye, Gauge, Layers3, Move3D, RotateCcw, Upload } from 'lucide-react'
import { getCell, getGeneratedModelUrl, getOrganelleDetail } from '../domain/cellCatalog.js'
import { downloadCanvasImage } from '../lib/downloads.js'
import { getSceneProfile } from '../lib/assetIntelligence.js'
import { downloadLayeredPngSnapshot } from '../lib/imagePipeline.js'
import { formatBytes, formatDuration, formatNumber, getModelQuality, inspectModelUrl } from '../lib/modelQuality.js'
import { inferMotionProfile } from '../lib/motionProfiles.js'
import { canUseWebGL } from '../lib/webgl.js'
import { getProviderLabel } from '../services/modelApi.js'
import { CellFallback, CellScene, CinematicLayerVisual, ViewerErrorBoundary } from '../viewer/CellViewer.jsx'
function ViewerControls({ crossSection, setCrossSection, viewMode, setViewMode, supportsPartControls, onModeChange }) {
const modes = [
{ id: 'solid', icon: Box, label: 'Solid view', status: 'Solid' },
{ id: 'layers', icon: Layers3, label: 'X-Ray layer view', status: 'X-Ray' },
{ id: 'focus', icon: CircleDot, label: 'Inspect focus view', status: 'Inspect' },
]
return (
<div className="viewer-controls">
<span>View Mode</span>
<div className="mode-buttons">
{modes.map((mode) => {
const Icon = mode.icon
return (
<button
key={mode.id}
type="button"
className={viewMode === mode.id ? 'active' : ''}
onClick={() => {
setViewMode(mode.id)
onModeChange?.(mode.status)
}}
title={mode.label}
aria-label={mode.label}
>
<Icon size={17} />
</button>
)
})}
</div>
{supportsPartControls && (
<label className="toggle-row" title="Cut into starter structural models">
<span>Cross-Section</span>
<input
type="checkbox"
checked={crossSection}
onChange={(event) => setCrossSection(event.target.checked)}
/>
<i />
</label>
)}
</div>
)
}
export function CenterStage({
selectedCell,
selectedOrganelle,
setSelectedOrganelle,
crossSection,
setCrossSection,
renderQuality,
screenshotScale = 1,
customCells,
generationHistory = [],
demoMode = false,
onNotify,
onExport,
exportAvailable,
exportReason,
onExporterReady,
onRetryGeneration,
onOpenInspector,
}) {
const [viewMode, setViewMode] = useState('solid')
const [autoRotate, setAutoRotate] = useState(false)
const [isIsolated, setIsIsolated] = useState(false)
const [hideOthers, setHideOthers] = useState(false)
const [proofMode, setProofMode] = useState(false)
const [resetNonce, setResetNonce] = useState(0)
const [capturePulse, setCapturePulse] = useState(false)
const [viewerError, setViewerError] = useState(null)
const [modelMetrics, setModelMetrics] = useState(null)
const cell = getCell(selectedCell, customCells)
const modelCellId = cell.custom ? cell.template : selectedCell
const referenceImageUrl = cell.custom ? cell.imageUrl || cell.thumbnailUrl || '' : ''
const generatedModelUrl = getGeneratedModelUrl(cell)
const generation = cell.custom ? cell.generation : null
const generationProviderLabel = getProviderLabel(generation?.provider)
const generationFailureTitle = generation?.requestedProvider === 'auto' ? '3D generation failed' : `${generationProviderLabel} generation failed`
const isCinematicCell = cell.custom && generation?.provider === 'cinematic'
const supportsPartControls = !cell.custom && !generatedModelUrl && !isCinematicCell
const effectiveAutoRotate = autoRotate || demoMode
const effectiveHideOthers = demoMode || !supportsPartControls ? false : hideOthers || viewMode === 'focus'
const effectiveIsolated = demoMode ? false : isIsolated || viewMode === 'focus'
const effectiveProofMode = demoMode ? false : proofMode
const effectiveViewMode = demoMode ? 'solid' : viewMode
const effectiveCrossSection = supportsPartControls && (crossSection || effectiveViewMode === 'layers')
const effectiveStatusMode = effectiveViewMode === 'layers' ? 'X-Ray' : effectiveViewMode === 'focus' ? 'Inspect' : 'Solid'
const detail = getOrganelleDetail(selectedCell, selectedOrganelle, customCells)
const webglAvailable = canUseWebGL()
const generationPending = cell.custom && !generatedModelUrl && generation?.status && !['failed', 'local'].includes(generation.status)
const generationFailed = cell.custom && !generatedModelUrl && generation?.status === 'failed'
const stageStatusText = isCinematicCell
? `JS image relief · ${effectiveAutoRotate ? 'Auto orbit' : 'Manual orbit'} · ${effectiveStatusMode}`
: `${generatedModelUrl ? `${generationProviderLabel} GLB loaded` : generationFailed ? `${generationProviderLabel} failed; source image shown` : referenceImageUrl ? `${generationProviderLabel} ${generation?.status || 'pending'}` : webglAvailable ? 'WebGL live 3D' : 'Fallback image'} · ${effectiveAutoRotate || effectiveProofMode ? 'Auto rotate' : 'Manual orbit'} · ${effectiveStatusMode}`
const referenceLabel = isCinematicCell
? 'Source image used for browser-side JS depth relief'
: generatedModelUrl
? `Source image used for ${generationProviderLabel} 3D generation`
: `Source image for ${generationProviderLabel} generation`
const viewerResetKey = `${selectedCell}-${generatedModelUrl}-${generation?.provider || 'built-in'}-${resetNonce}`
const activeViewerError = viewerError?.key === viewerResetKey ? viewerError.message : ''
const activeModelMetrics = modelMetrics?.url === generatedModelUrl ? modelMetrics.data : null
const quality = useMemo(() => getModelQuality(cell, activeModelMetrics, generationHistory), [activeModelMetrics, cell, generationHistory])
const motionProfile = useMemo(() => inferMotionProfile(cell), [cell])
const sceneProfile = useMemo(() => getSceneProfile(cell), [cell])
const viewerFallback = (
<CellFallback
selectedCell={selectedCell}
modelCellId={modelCellId}
referenceImageUrl={referenceImageUrl}
selectedOrganelle={selectedOrganelle}
onSelectOrganelle={setSelectedOrganelle}
/>
)
function handleRotate() {
const next = !autoRotate
setAutoRotate(next)
onNotify(next ? 'Auto rotate enabled' : 'Auto rotate paused')
}
function handleIsolate() {
if (!supportsPartControls) return
const next = !isIsolated
setIsIsolated(next)
if (next) setViewMode('focus')
onNotify(next ? `${detail.title} focus mode` : 'Focus mode off')
}
function handleHideOthers() {
if (!supportsPartControls) return
const next = !hideOthers
setHideOthers(next)
onNotify(next ? `Showing ${detail.title} with model shell` : 'All structures visible')
}
function handleResetView() {
setAutoRotate(false)
setIsIsolated(false)
setHideOthers(false)
setProofMode(false)
setViewMode('solid')
setResetNonce((value) => value + 1)
onNotify('View reset')
}
function handleProofMode() {
const next = !proofMode
setProofMode(next)
if (next) {
setViewMode('focus')
setHideOthers(false)
setAutoRotate(true)
onOpenInspector?.()
}
onNotify(next ? 'Inspection mode enabled' : 'Inspection mode off')
}
function handleViewModeChange(modeLabel) {
onNotify(`${modeLabel} view enabled`)
}
async function handleScreenshot() {
const ok = isCinematicCell && referenceImageUrl
? (webglAvailable ? downloadCanvasImage(`${selectedCell}-${selectedOrganelle}.png`, screenshotScale) : await downloadLayeredPngSnapshot(referenceImageUrl, `${selectedCell}-${selectedOrganelle}.png`))
: downloadCanvasImage(`${selectedCell}-${selectedOrganelle}.png`, screenshotScale)
setCapturePulse(true)
window.setTimeout(() => setCapturePulse(false), 280)
onNotify(ok ? 'Screenshot downloaded' : 'Screenshot unavailable in this browser')
}
function handleViewerError(error) {
console.error(error)
const message = error instanceof Error ? error.message : 'The saved 3D preview could not be loaded.'
setViewerError({ key: viewerResetKey, message })
onExporterReady?.(null)
onNotify('3D preview unavailable; fallback view shown')
}
useEffect(() => {
if (isCinematicCell) onExporterReady?.(null)
}, [isCinematicCell, onExporterReady])
useEffect(() => {
let cancelled = false
if (!generatedModelUrl) return undefined
inspectModelUrl(generatedModelUrl)
.then((metrics) => {
if (!cancelled) setModelMetrics({ url: generatedModelUrl, data: metrics })
})
.catch((error) => {
if (!cancelled) {
setModelMetrics({ url: generatedModelUrl, data: { error: error instanceof Error ? error.message : 'Model metrics unavailable.' } })
}
})
return () => {
cancelled = true
}
}, [generatedModelUrl])
return (
<section className={`stage-panel motion-${motionProfile.id} scene-${sceneProfile.id}`}>
<div className="stage-title">
<div>
<h1>{cell.name}</h1>
<p>{cell.type}</p>
</div>
</div>
<ViewerControls
crossSection={crossSection}
setCrossSection={setCrossSection}
viewMode={viewMode}
setViewMode={setViewMode}
supportsPartControls={supportsPartControls}
onModeChange={handleViewModeChange}
/>
{demoMode && <PresentationMotionField profile={sceneProfile.id} />}
{demoMode && <DemoShowcaseOverlay cell={cell} quality={quality} referenceImageUrl={referenceImageUrl} motionProfile={motionProfile} sceneProfile={sceneProfile} />}
{!demoMode && <ModelQualityCard quality={quality} />}
<div className={`cell-viewer ${effectiveViewMode} ${effectiveIsolated ? 'is-isolated' : ''} ${generatedModelUrl ? 'has-glb' : ''} ${webglAvailable ? 'webgl-ready' : ''} ${isCinematicCell ? 'cinematic-viewer' : ''}`}>
<ViewerErrorBoundary resetKey={viewerResetKey} onError={handleViewerError} fallback={viewerFallback}>
{isCinematicCell ? (
<CinematicLayerVisual
imageUrl={referenceImageUrl}
selectedOrganelle={selectedOrganelle}
onSelectOrganelle={setSelectedOrganelle}
autoRotate={effectiveAutoRotate || effectiveProofMode}
presentationMode={demoMode}
motionProfile={sceneProfile.id}
viewMode={effectiveViewMode}
/>
) : (
<>
<CellFallback selectedCell={selectedCell} modelCellId={modelCellId} referenceImageUrl={referenceImageUrl} selectedOrganelle={selectedOrganelle} onSelectOrganelle={setSelectedOrganelle} />
{!generationFailed && (
<CellScene
key={`${selectedCell}-${resetNonce}`}
selectedCell={selectedCell}
modelCellId={modelCellId}
referenceImageUrl={referenceImageUrl}
generatedModelUrl={generatedModelUrl}
selectedOrganelle={selectedOrganelle}
crossSection={effectiveCrossSection}
autoRotate={effectiveAutoRotate}
hideOthers={effectiveHideOthers}
proofMode={effectiveProofMode}
viewMode={effectiveViewMode}
renderQuality={renderQuality}
presentationMode={demoMode}
motionProfile={sceneProfile.id}
onSelectOrganelle={setSelectedOrganelle}
onExporterReady={onExporterReady}
/>
)}
</>
)}
</ViewerErrorBoundary>
</div>
{referenceImageUrl && (
<div className="custom-reference-layer">
<img src={referenceImageUrl} alt={`${cell.name} uploaded reference`} />
<span>{referenceLabel}</span>
</div>
)}
{generationPending && (
<div className="generation-overlay">
<strong>{generation.status === 'uploading' ? `Uploading to ${generationProviderLabel}` : `Generating with ${generationProviderLabel}`}</strong>
<span>{generation.message || 'Waiting for AI-generated GLB...'}</span>
<div className="generation-meter">
<i />
</div>
</div>
)}
{generationFailed && (
<div className="generation-overlay failed">
<strong>{generationFailureTitle}</strong>
<span>{generation.message || 'The saved upload failed before a GLB was returned.'}</span>
<button type="button" onClick={() => onRetryGeneration?.(cell.id)}>Retry Generation</button>
</div>
)}
{activeViewerError && !generationFailed && (
<div className="generation-overlay failed">
<strong>3D preview unavailable</strong>
<span>{generatedModelUrl ? 'The saved GLB could not be loaded. Showing the saved source image or fallback model instead.' : activeViewerError}</span>
{cell.custom && !cell.reference && cell.imageUrl && <button type="button" onClick={() => onRetryGeneration?.(cell.id)}>Retry Generation</button>}
</div>
)}
<div className="stage-status">
{stageStatusText}
</div>
{capturePulse && <div className="capture-pulse" />}
<div className={`stage-toolbar ${supportsPartControls ? 'with-structure' : 'compact-tools'}`}>
<button type="button" className={autoRotate ? 'active' : ''} onClick={handleRotate} aria-pressed={autoRotate}>
<Move3D size={14} />
Rotate
</button>
{supportsPartControls && (
<button
type="button"
className={isIsolated ? 'active' : ''}
onClick={handleIsolate}
aria-pressed={isIsolated}
title="Focus the selected starter model part"
>
<Eye size={14} />
Focus Part
</button>
)}
{supportsPartControls && (
<button
type="button"
className={hideOthers ? 'active' : ''}
onClick={handleHideOthers}
aria-pressed={hideOthers}
title="Hide non-selected starter model parts"
>
<Layers3 size={14} />
Hide Parts
</button>
)}
<button type="button" onClick={handleResetView}>
<RotateCcw size={14} />
Reset View
</button>
<button type="button" className={proofMode ? 'active proof-active' : ''} onClick={handleProofMode} aria-pressed={proofMode}>
<Box size={14} />
Inspect
</button>
<span />
<button type="button" onClick={handleScreenshot}>
<Camera size={14} />
Screenshot
</button>
<button type="button" onClick={onExport} disabled={!exportAvailable} title={exportReason}>
<Upload size={14} />
3D Export
</button>
</div>
</section>
)
}
function PresentationMotionField({ profile }) {
if (!['road', 'aircraft', 'vessel', 'artifact', 'product', 'specimen'].includes(profile)) return null
return (
<div className={`presentation-motion-field ${profile}`} aria-hidden="true">
<span />
<span />
<span />
<span />
<span />
<span />
</div>
)
}
function ModelQualityCard({ quality }) {
return (
<aside className="model-quality-card" aria-label="Model quality score">
<div className="quality-score">
<Gauge size={16} />
<strong>{quality.score}</strong>
<span>{quality.verdict}</span>
</div>
<div className="quality-stats">
<span><strong>{quality.hasGlb ? 'Yes' : 'No'}</strong><small>GLB</small></span>
<span><strong>{quality.loadingMetrics ? '...' : formatBytes(quality.fileBytes)}</strong><small>file</small></span>
<span><strong>{quality.loadingMetrics ? '...' : formatNumber(quality.triangleCount)}</strong><small>tris</small></span>
<span><strong>{quality.loadingMetrics ? '...' : quality.textureCount}</strong><small>textures</small></span>
</div>
</aside>
)
}
function DemoShowcaseOverlay({ cell, quality, referenceImageUrl, motionProfile, sceneProfile }) {
return (
<div className="demo-showcase-overlay">
<div className="demo-showcase-title">
<span>3D Model Studio</span>
<strong>{cell.name}</strong>
<small>{quality.providerLabel} · {quality.hasGlb ? 'GLB asset' : quality.status} · {quality.verdict} · {motionProfile.label}</small>
<p>{sceneProfile.summary}</p>
<div className="demo-scene-badges">
{sceneProfile.badges.map((badge) => (
<em key={badge}>{badge}</em>
))}
</div>
</div>
<div className="demo-metric-strip">
<span><strong>{quality.score}</strong><small>score</small></span>
<span><strong>{formatBytes(quality.fileBytes)}</strong><small>file</small></span>
<span><strong>{formatNumber(quality.triangleCount)}</strong><small>triangles</small></span>
<span><strong>{quality.textureCount}</strong><small>textures</small></span>
<span><strong>{formatDuration(quality.durationMs)}</strong><small>time</small></span>
</div>
{referenceImageUrl && (
<div className="demo-source-thumb">
<img src={referenceImageUrl} alt={`${cell.name} source reference`} />
<span>source</span>
</div>
)}
</div>
)
}
+82
View File
@@ -0,0 +1,82 @@
import { Bookmark, Heart, Info, Sparkles, Tags } from 'lucide-react'
import { getCell } from '../domain/cellCatalog.js'
import { getAssetMetadata } from '../lib/assetMetadata.js'
export function DetailPanel({ selectedCell, favoriteKey, setFavoriteKey, customCells, onNotify }) {
const cell = getCell(selectedCell, customCells)
const metadata = getAssetMetadata(cell)
const currentKey = `${selectedCell}:asset`
const isFavorite = favoriteKey === currentKey
function toggleFavorite() {
const next = isFavorite ? '' : currentKey
setFavoriteKey(next)
onNotify(isFavorite ? `${metadata.title} removed from favorites` : `${metadata.title} saved to favorites`)
}
return (
<aside className="right-rail">
<section className="panel detail-panel">
<header className="detail-title">
<span>
<Info size={14} />
Asset Details
</span>
<button type="button" className={isFavorite ? 'detail-fav active' : 'detail-fav'} onClick={toggleFavorite} aria-pressed={isFavorite}>
<Heart size={15} fill={isFavorite ? 'currentColor' : 'none'} />
</button>
</header>
<div className="detail-heading asset-heading">
<div className="cluster-icon asset-icon" style={{ '--cluster': metadata.accent }}>
<span />
<span />
<span />
<span />
</div>
<div>
<h2>{metadata.title}</h2>
<p>{metadata.subtitle}</p>
</div>
</div>
<dl className="detail-grid">
{metadata.facts.map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
</section>
<section className="panel notes-panel">
<header className="panel-title">
<span>
<Bookmark size={14} />
Object Description
</span>
</header>
<p>{metadata.description}</p>
<blockquote>{metadata.value}</blockquote>
</section>
<section className="panel asset-tags-panel">
<header className="panel-title">
<span>
<Tags size={14} />
Tags
</span>
</header>
<div className="asset-tag-list">
{metadata.tags.map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
<p>
<Sparkles size={13} />
Inferred from {metadata.insightSource}.
</p>
</section>
</aside>
)
}
+90
View File
@@ -0,0 +1,90 @@
import { AlertTriangle, CheckCircle2, Clock3, RotateCcw } from 'lucide-react'
import { getProviderLabel } from '../services/modelApi.js'
import { CellThumb } from './CellThumb.jsx'
const ACTIVE_STATUSES = new Set(['uploading', 'processing', 'queued', 'running', 'pending'])
const SUCCESS_STATUSES = new Set(['success', 'local'])
export function GenerationTaskCenter({ customCells = [], generationHistory = [], selectedCell, onOpenCell, onRetryGeneration, onRunProviderCompare }) {
const tasks = customCells
.filter((cell) => cell.generation && !cell.reference)
.slice(0, 6)
const selectedCustomCell = customCells.find((cell) => cell.id === selectedCell && cell.imageUrl)
const recentHistory = generationHistory.slice(0, 4)
const activeCount = tasks.filter((cell) => ACTIVE_STATUSES.has(String(cell.generation?.status || '').toLowerCase())).length
return (
<section className="panel task-panel">
<header className="panel-title">
<span>Generation Queue</span>
<small>{activeCount || tasks.length}</small>
</header>
{selectedCustomCell && (
<div className="task-actions">
<button type="button" onClick={() => onRunProviderCompare(selectedCustomCell.id)}>Compare Providers</button>
</div>
)}
{tasks.length === 0 ? (
<div className="task-empty">
<Clock3 size={15} />
<span>Upload an image or GLB to start a model job.</span>
</div>
) : (
<div className="task-list">
{tasks.map((cell) => {
const generation = cell.generation || {}
const status = String(generation.status || 'pending').toLowerCase()
const failed = status === 'failed'
const done = SUCCESS_STATUSES.has(status)
const active = ACTIVE_STATUSES.has(status)
const providerLabel = getProviderLabel(generation.provider || generation.requestedProvider)
return (
<div key={cell.id} className={selectedCell === cell.id ? 'task-row active' : 'task-row'}>
<button type="button" className="task-open" onClick={() => onOpenCell(cell.id)}>
<CellThumb cell={cell} selected={selectedCell === cell.id} />
<span>
<strong>{cell.name}</strong>
<small>{providerLabel} · {formatTaskStatus(status, generation.progress)}</small>
</span>
</button>
<span className={failed ? 'task-state failed' : done ? 'task-state done' : active ? 'task-state active' : 'task-state'}>
{failed ? <AlertTriangle size={14} /> : done ? <CheckCircle2 size={14} /> : <Clock3 size={14} />}
</span>
{failed && (
<button type="button" className="task-retry" onClick={() => onRetryGeneration(cell.id)} aria-label={`Retry ${cell.name}`}>
<RotateCcw size={13} />
</button>
)}
</div>
)
})}
</div>
)}
{recentHistory.length > 0 && (
<div className="task-history">
<strong>History</strong>
{recentHistory.map((item) => (
<button key={item.id} type="button" className="task-history-row" onClick={() => onOpenCell(item.cellId)}>
<span>{item.cellName}</span>
<small>{getProviderLabel(item.provider)} · {formatTaskStatus(String(item.status || '').toLowerCase(), item.progress)}</small>
</button>
))}
</div>
)}
</section>
)
}
function formatTaskStatus(status, progress) {
if (status === 'success') return 'ready'
if (status === 'local') return 'local ready'
if (status === 'failed') return 'failed'
if (Number.isFinite(progress)) return `${progress}%`
if (status === 'uploading') return 'uploading'
if (status === 'processing' || status === 'running') return 'generating'
if (status === 'queued' || status === 'pending') return 'queued'
return status || 'pending'
}
+153
View File
@@ -0,0 +1,153 @@
import { AlertTriangle, CheckCircle2, ChevronDown, Clock3, Heart, RotateCcw, Sparkles as SparklesIcon, Trash2 } from 'lucide-react'
import { CELL_TYPES } from '../domain/cellData.js'
import { getCell, getPrimaryCells } from '../domain/cellCatalog.js'
import { getProviderLabel } from '../services/modelApi.js'
import { CellThumb } from './CellThumb.jsx'
const ACTIVE_STATUSES = new Set(['uploading', 'processing', 'queued', 'running', 'pending'])
const READY_STATUSES = new Set(['success', 'local'])
export function LeftSidebar({ selectedCell, setSelectedCell, customCells, onDeleteCustomCell, onRetryGeneration }) {
const libraryCells = getPrimaryCells(customCells).filter((cell) => cell.custom && !cell.reference)
const activeModel = getCell(selectedCell, customCells)
const activeIsCustom = Boolean(activeModel.custom && !activeModel.reference)
const recentCells = libraryCells.filter((cell) => cell.id !== activeModel.id)
const starterCells = CELL_TYPES.filter((cell) => cell.id !== activeModel.id)
const queueItems = libraryCells.filter((cell) => cell.generation)
const storedCustomIds = new Set(customCells.map((cell) => cell.id))
const queueCount = queueItems.filter((cell) => ACTIVE_STATUSES.has(String(cell.generation?.status || '').toLowerCase())).length || queueItems.length
function renderCellRow(cell, { compact = false } = {}) {
const canDelete = Boolean(cell.custom && storedCustomIds.has(cell.id))
const generation = cell.generation || {}
const providerLabel = cell.custom ? getProviderLabel(generation.provider || generation.requestedProvider) : 'Starter'
const status = cell.custom ? formatQueueStatus(String(generation.status || 'ready').toLowerCase(), generation.progress) : 'ready'
return (
<div key={cell.id} className={canDelete ? 'cell-row-shell can-delete' : 'cell-row-shell'}>
<button
type="button"
className={`${selectedCell === cell.id ? 'cell-row active' : 'cell-row'}${compact ? ' compact' : ''}`}
onClick={() => setSelectedCell(cell.id)}
>
<CellThumb cell={cell} selected={selectedCell === cell.id} />
<span>
<strong>{cell.name}</strong>
<small>{providerLabel} · {status}</small>
</span>
{!canDelete && selectedCell === cell.id && <Heart size={13} fill="currentColor" />}
</button>
{canDelete && (
<button type="button" className="cell-delete" aria-label={`Delete ${cell.name}`} onClick={() => onDeleteCustomCell?.(cell.id)}>
<Trash2 size={12} />
</button>
)}
</div>
)
}
return (
<aside className="left-rail">
<section className="panel cell-types-panel">
<header className="panel-title">
<span>
<SparklesIcon size={14} />
Model Library
</span>
<ChevronDown size={14} />
</header>
<div className="pinned-models">
<div className="pinned-model-block">
<span className="model-section-label">{activeIsCustom ? 'Active Asset' : 'Active Starter'}</span>
{renderCellRow(activeModel)}
</div>
</div>
<div className="cell-list">
{recentCells.length > 0 && (
<div className="recent-cells">
<div className="recent-toggle" aria-expanded="true">
<span>Saved Assets</span>
<small>{recentCells.length}</small>
<ChevronDown size={13} />
</div>
<div className="recent-cell-list">
{recentCells.map((cell) => renderCellRow(cell, { compact: true }))}
</div>
</div>
)}
<div className="starter-cells">
<span className="model-section-label">Starter Models</span>
<div className="starter-cell-list">
{starterCells.map((cell) => renderCellRow(cell, { compact: true }))}
</div>
</div>
{libraryCells.length === 0 && (
<div className="library-empty compact-empty">
<SparklesIcon size={16} />
<span>No saved uploads yet.</span>
<small>Use Asset Source to add your own image or GLB.</small>
</div>
)}
</div>
</section>
<section className="panel organelles-panel">
<header className="panel-title">
<span>
<Clock3 size={14} />
Generation Queue
</span>
<small>{queueCount}</small>
</header>
{queueItems.length === 0 ? (
<div className="queue-empty">
<Clock3 size={15} />
<span>No generation jobs yet.</span>
</div>
) : (
<div className="left-queue-list">
{queueItems.map((cell) => {
const generation = cell.generation || {}
const status = String(generation.status || 'pending').toLowerCase()
const failed = status === 'failed'
const ready = READY_STATUSES.has(status)
const active = ACTIVE_STATUSES.has(status)
return (
<div key={cell.id} className={selectedCell === cell.id ? 'left-queue-row active' : 'left-queue-row'}>
<button type="button" onClick={() => setSelectedCell(cell.id)}>
<CellThumb cell={cell} selected={selectedCell === cell.id} />
<span>
<strong>{cell.name}</strong>
<small>{getProviderLabel(generation.provider || generation.requestedProvider)} · {formatQueueStatus(status, generation.progress)}</small>
</span>
</button>
<span className={failed ? 'queue-state failed' : ready ? 'queue-state ready' : active ? 'queue-state active' : 'queue-state'}>
{failed ? <AlertTriangle size={13} /> : ready ? <CheckCircle2 size={13} /> : <Clock3 size={13} />}
</span>
{failed && (
<button type="button" className="queue-retry" onClick={() => onRetryGeneration?.(cell.id)} aria-label={`Retry ${cell.name}`}>
<RotateCcw size={12} />
</button>
)}
</div>
)
})}
</div>
)}
</section>
</aside>
)
}
function formatQueueStatus(status, progress) {
if (status === 'success') return 'ready'
if (status === 'local') return 'local ready'
if (status === 'failed') return 'failed'
if (Number.isFinite(progress)) return `${progress}%`
if (status === 'uploading') return 'uploading'
if (status === 'processing' || status === 'running') return 'generating'
if (status === 'queued' || status === 'pending') return 'queued'
return status || 'pending'
}
+8
View File
@@ -0,0 +1,8 @@
export function StatusToast({ message }) {
return (
<div className="status-toast">
<span />
{message}
</div>
)
}
+78
View File
@@ -0,0 +1,78 @@
import { BookOpen, Box, ChevronDown, Grid3X3, Library, MonitorPlay, ScrollText, Settings } from 'lucide-react'
const HEADER_TEXT = {
en: {
title: '3D Model Studio',
subtitle: 'Generate, inspect, and present 3D assets',
Gallery: 'Gallery',
Library: 'Library',
Notebooks: 'Notebooks',
Logs: 'Logs',
Settings: 'Settings',
Demo: 'Demo',
},
zh: {
title: '3D模型工作室',
subtitle: '生成、检查和演示3D模型',
Gallery: '作品集',
Library: '模型库',
Notebooks: '笔记',
Logs: '日志',
Settings: '设置',
Demo: '演示',
},
}
export function StudioHeader({ activePanel, setActivePanel, demoMode, language = 'en', onToggleDemoMode, onNotify }) {
const text = HEADER_TEXT[language] || HEADER_TEXT.en
function openPanel(panel) {
const next = activePanel === panel ? null : panel
setActivePanel(next)
onNotify(next ? `${panel} opened` : `${panel} closed`)
}
return (
<header className="studio-header">
<div className="studio-brand">
<div className="brand-mark">
<Box size={30} />
</div>
<div>
<strong>{text.title}</strong>
<span>{text.subtitle}</span>
</div>
</div>
<nav className="studio-nav">
<button type="button" className={activePanel === 'Gallery' ? 'active' : ''} onClick={() => openPanel('Gallery')}>
<Grid3X3 size={15} />
{text.Gallery}
</button>
<button type="button" className={activePanel === 'Library' ? 'active' : ''} onClick={() => openPanel('Library')}>
<Library size={15} />
{text.Library}
</button>
<button type="button" className={activePanel === 'Notebooks' ? 'active' : ''} onClick={() => openPanel('Notebooks')}>
<BookOpen size={15} />
{text.Notebooks}
</button>
<button type="button" className={activePanel === 'Logs' ? 'active' : ''} onClick={() => openPanel('Logs')}>
<ScrollText size={15} />
{text.Logs}
</button>
<button type="button" className={activePanel === 'Settings' ? 'active' : ''} onClick={() => openPanel('Settings')}>
<Settings size={15} />
{text.Settings}
</button>
<button type="button" className={demoMode ? 'active' : ''} onClick={onToggleDemoMode}>
<MonitorPlay size={15} />
{text.Demo}
</button>
</nav>
<button type="button" className={activePanel === 'Profile' ? 'profile-button active' : 'profile-button'} onClick={() => openPanel('Profile')}>
<Box size={18} />
<ChevronDown size={13} />
</button>
</header>
)
}
+747
View File
@@ -0,0 +1,747 @@
import { motion } from 'framer-motion'
import { Box, CheckCircle2, Clock3, Copy, Download, Edit3, Image, Layers3, RefreshCw, RotateCcw, Trash2, X } from 'lucide-react'
import { FAL_MODEL_OPTIONS, GENERATION_MODE_OPTIONS, LANGUAGE_OPTIONS, SCREENSHOT_SCALE_OPTIONS } from '../config/appConfig.js'
import { CELL_TYPES, WORKSPACE_PANELS } from '../domain/cellData.js'
import { getCell, getCellProfile, getOrganelleDetail } from '../domain/cellCatalog.js'
import { getProviderLabel } from '../services/modelApi.js'
import { CellThumb } from './CellThumb.jsx'
const READY_STATUSES = new Set(['success', 'local'])
const ACTIVE_STATUSES = new Set(['uploading', 'processing', 'queued', 'running', 'pending'])
function findCell(cells, cellId) {
return cells.find((cell) => cell.id === cellId) ?? getCell(cellId)
}
function formatDate(value) {
if (!value) return 'Not saved'
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(value))
}
function formatDuration(ms) {
if (!Number.isFinite(ms)) return 'n/a'
if (ms < 1000) return `${Math.round(ms)} ms`
return `${Math.round(ms / 1000)} s`
}
function getModelUrl(cell) {
return cell.generation?.modelUrl || ''
}
function getModelSource(cell) {
if (cell.reference) return 'Khronos glTF Sample Models'
if (cell.generation?.provider === 'local') return 'Local GLB import'
if (cell.generation?.provider === 'cinematic') return 'Browser JS Depth'
if (cell.custom) return `${cell.generation?.provider || 'AI'} generation`
return 'Procedural Three.js scene'
}
function getQualityLabel(cell) {
if (cell.reference) return 'Reference GLB'
if (cell.generation?.modelUrl) return 'GLB ready'
if (cell.generation?.status === 'failed') return 'Failed'
if (cell.generation?.status) return cell.generation.status
return 'Interactive'
}
function getAssetPreviewUrl(cell) {
return cell.thumbnailUrl || cell.imageUrl || ''
}
function formatAssetStatus(cell) {
const status = String(cell.generation?.status || '').toLowerCase()
if (cell.reference) return 'reference'
if (READY_STATUSES.has(status) || cell.generation?.modelUrl) return 'ready'
if (status === 'failed') return 'failed'
if (ACTIVE_STATUSES.has(status)) return 'generating'
if (cell.custom) return 'queued'
return 'starter'
}
function getAssetTone(cell) {
const status = formatAssetStatus(cell)
if (status === 'ready' || status === 'reference') return 'ready'
if (status === 'failed') return 'failed'
if (status === 'generating' || status === 'queued') return 'active'
return 'starter'
}
function getAssetKind(cell) {
if (cell.reference) return 'Reference GLB'
if (cell.generation?.provider === 'local') return 'Local Import'
if (cell.generation?.provider === 'cinematic') return 'JS Depth Preview'
if (cell.generation?.modelUrl) return 'Generated GLB'
if (cell.custom) return 'Generated Asset'
return 'Starter Scene'
}
function getAssetRuntime(cell, generationHistory) {
const match = generationHistory.find((entry) => entry.cellId === cell.id && Number.isFinite(entry.durationMs))
return match ? formatDuration(match.durationMs) : 'n/a'
}
function formatLogSummary(entry) {
const parts = [
entry.method,
entry.path,
entry.provider,
entry.status ? `status=${entry.status}` : '',
entry.progress !== undefined && entry.progress !== null ? `progress=${entry.progress}` : '',
entry.taskId ? `task=${String(entry.taskId).slice(0, 18)}` : '',
entry.durationMs !== undefined ? `duration=${formatDuration(entry.durationMs)}` : '',
entry.error?.message || entry.error || '',
].filter(Boolean)
return parts.join(' · ') || JSON.stringify(entry).slice(0, 160)
}
export function WorkspaceDrawer({
activePanel,
selectedCell,
selectedOrganelle,
compareCell,
allCells = CELL_TYPES,
customCells = [],
galleryItems,
generationHistory = [],
notes,
settings,
projects = [],
crossSection,
selectedMicroscope,
uploadedImage,
favoriteKey,
onClose,
onSelectCell,
onSelectOrganelle,
onSetCompareCell,
onSaveGallery,
onClearGallery,
onRestoreGalleryItem,
onRenameGalleryItem,
onDeleteGalleryItem,
onDownloadGalleryImage,
onExportGallery,
onDeleteCustomCell,
onClearGenerationHistory,
onUpdateNote,
onGenerateNote,
onCopyNote,
onExportNote,
onUpdateSettings,
onSetCrossSection,
onExport,
exportAvailable,
exportReason,
apiHealth,
serverLogs,
onRefreshApiHealth,
onRefreshServerLogs,
onExportDiagnostics,
onClearWorkspaceCache,
onResetWorkspace,
onSaveProject,
onLoadProject,
onDeleteProject,
onExportProject,
onRunProviderCompare,
onCopyText,
}) {
if (!activePanel) return null
const cell = findCell(allCells, selectedCell)
const compare = findCell(allCells, compareCell)
const detail = getOrganelleDetail(selectedCell, selectedOrganelle, customCells)
const profile = getCellProfile(selectedCell, customCells)
const noteKey = `${selectedCell}:${selectedOrganelle}`
const noteValue = notes[noteKey] ?? ''
const savedFavorite = favoriteKey ? favoriteKey.replace(':', ' / ') : 'None'
const generatedAssets = allCells.filter((item) => item.custom && !item.reference)
const referenceAssets = allCells.filter((item) => item.reference)
const starterAssets = allCells.filter((item) => !item.custom && !item.reference)
const readyGeneratedAssets = generatedAssets.filter((item) => formatAssetStatus(item) === 'ready')
function renderAssetCard(item, { compact = false } = {}) {
const modelUrl = getModelUrl(item)
const previewUrl = getAssetPreviewUrl(item)
const providerLabel = getProviderLabel(item.generation?.provider || item.generation?.requestedProvider || (item.reference ? 'reference' : 'built-in'))
const canDelete = customCells.some((candidate) => candidate.id === item.id) && !item.reference
const canCompare = item.custom && !item.reference && Boolean(item.imageUrl)
const status = formatAssetStatus(item)
const taskId = item.generation?.taskId || ''
return (
<article key={item.id} className={`${selectedCell === item.id ? 'asset-library-card active' : 'asset-library-card'} tone-${getAssetTone(item)}${compact ? ' compact' : ''}`}>
<button type="button" className="asset-preview-frame" onClick={() => onSelectCell(item.id)} aria-label={`Open ${item.name}`}>
{previewUrl ? <img src={previewUrl} alt={`${item.name} source preview`} /> : <CellThumb cell={item} selected={selectedCell === item.id} />}
</button>
<div className="asset-library-body">
<div className="asset-library-title">
<span>
<strong title={item.fullName || item.name}>{item.fullName || item.name}</strong>
<small>{getAssetKind(item)} · {providerLabel}</small>
</span>
<span className={`asset-status-pill ${status}`}>
{status === 'ready' || status === 'reference' ? <CheckCircle2 size={12} /> : status === 'failed' ? <X size={12} /> : <Clock3 size={12} />}
{status}
</span>
</div>
<div className="asset-stat-grid">
<span><strong>{modelUrl ? 'GLB' : 'Preview'}</strong><small>asset</small></span>
<span><strong>{getAssetRuntime(item, generationHistory)}</strong><small>runtime</small></span>
<span><strong>{taskId ? String(taskId).slice(0, 8) : 'none'}</strong><small>task</small></span>
</div>
<code className="asset-model-url">{modelUrl || item.referenceSource || item.type || 'Procedural preview only'}</code>
<div className="asset-library-actions">
<button type="button" onClick={() => onSelectCell(item.id)}>Open</button>
<button type="button" disabled={!modelUrl} onClick={() => onCopyText(modelUrl, 'Model URL copied')}>
<Copy size={12} />
URL
</button>
<button type="button" disabled={!canCompare} onClick={() => onRunProviderCompare(item.id)}>
<RotateCcw size={12} />
Compare
</button>
{canDelete && (
<button type="button" className="danger" onClick={() => onDeleteCustomCell?.(item.id)}>
<Trash2 size={12} />
</button>
)}
</div>
</div>
</article>
)
}
function renderContent() {
if (activePanel === 'Gallery') {
return (
<div className="drawer-content">
<div className="gallery-hero">
<CellThumb cell={cell} selected />
<div>
<strong>{cell.name}</strong>
<span>{detail.title} · {selectedMicroscope}</span>
</div>
</div>
<div className="drawer-actions">
<button type="button" className="drawer-primary" onClick={onSaveGallery}>Save View</button>
<button type="button" className="drawer-secondary" onClick={onExport} disabled={!exportAvailable} title={exportReason}>Export GLB</button>
</div>
{uploadedImage && (
<div className="uploaded-tile" style={{ '--upload-preview': `url(${uploadedImage.url})` }}>
<span />
<div>
<strong>{uploadedImage.name}</strong>
<small>Attached source reference</small>
</div>
</div>
)}
<div className="drawer-list">
{galleryItems.length === 0 ? (
<p className="empty-state">No saved views yet.</p>
) : (
galleryItems.map((item) => {
const itemCell = findCell(allCells, item.cellId)
const itemDetail = getOrganelleDetail(item.cellId, item.organelleId, customCells)
return (
<article key={item.id} className="gallery-shot-card">
<button type="button" className="gallery-shot-preview" onClick={() => onRestoreGalleryItem(item)}>
{item.thumbnailUrl ? <img src={item.thumbnailUrl} alt={`${item.title || itemCell.name} saved view`} /> : <CellThumb cell={itemCell} selected={item.cellId === selectedCell} />}
</button>
<div className="gallery-shot-body">
<strong>{item.title || `${itemCell.name} / ${itemDetail.title}`}</strong>
<small>{itemCell.name} · {itemDetail.title} · {item.microscope}</small>
<small>{getQualityLabel({ generation: { provider: item.generationProvider, modelUrl: item.modelUrl } })} · {formatDate(item.createdAt)}</small>
</div>
<div className="gallery-shot-actions">
<button type="button" onClick={() => onRestoreGalleryItem(item)}>Open</button>
<button
type="button"
onClick={() => {
const title = window.prompt('Rename saved view', item.title || `${itemCell.name} / ${itemDetail.title}`)
if (title !== null) onRenameGalleryItem(item.id, title)
}}
>
<Edit3 size={12} />
</button>
<button type="button" onClick={() => onDownloadGalleryImage(item)} disabled={!item.thumbnailUrl}>
<Download size={12} />
</button>
<button type="button" onClick={() => onDeleteGalleryItem(item.id)}>
<Trash2 size={12} />
</button>
</div>
</article>
)
})
)}
</div>
{galleryItems.length > 0 && (
<div className="drawer-actions">
<button type="button" className="drawer-secondary" onClick={onExportGallery}>Export Gallery</button>
<button type="button" className="drawer-secondary" onClick={onClearGallery}>Clear Gallery</button>
</div>
)}
</div>
)
}
if (activePanel === 'Library') {
return (
<div className="drawer-content asset-library-drawer">
<div className="asset-library-summary">
<span><strong>{generatedAssets.length}</strong><small>generated/imported</small></span>
<span><strong>{readyGeneratedAssets.length}</strong><small>ready GLB</small></span>
<span><strong>{referenceAssets.length}</strong><small>references</small></span>
</div>
<section className="asset-library-section">
<header className="asset-section-head">
<span>
<Box size={15} />
<strong>Generated & Imported Assets</strong>
</span>
<small>{readyGeneratedAssets.length}/{generatedAssets.length} ready</small>
</header>
{generatedAssets.length === 0 ? (
<div className="asset-library-empty">
<Image size={18} />
<span>No generated assets yet.</span>
<small>Upload an image or import a GLB from Asset Source.</small>
</div>
) : (
<div className="asset-card-grid">
{generatedAssets.map((item) => renderAssetCard(item))}
</div>
)}
</section>
<section className="asset-library-section">
<header className="asset-section-head">
<span>
<Layers3 size={15} />
<strong>Khronos Reference GLB</strong>
</span>
<small>material checks</small>
</header>
<div className="asset-card-grid compact">
{referenceAssets.map((item) => renderAssetCard(item, { compact: true }))}
</div>
</section>
<details className="asset-library-section starter-assets">
<summary>
<span>Starter procedural scenes</span>
<small>{starterAssets.length}</small>
</summary>
<div className="starter-asset-grid">
{starterAssets.map((item) => (
<button key={item.id} type="button" className={selectedCell === item.id ? 'starter-asset active' : 'starter-asset'} onClick={() => onSelectCell(item.id)}>
<CellThumb cell={item} selected={selectedCell === item.id} />
<span>
<strong>{item.name}</strong>
<small>{item.type}</small>
</span>
</button>
))}
</div>
</details>
</div>
)
}
if (activePanel === 'Notebooks') {
const noteEntries = Object.entries(notes)
return (
<div className="drawer-content">
<label className="note-editor">
<span>{cell.name} / {detail.title}</span>
<textarea
value={noteValue}
onChange={(event) => onUpdateNote(noteKey, event.target.value)}
placeholder="Record observations, questions, or narration notes..."
/>
</label>
<div className="drawer-actions three">
<button type="button" className="drawer-primary" onClick={onGenerateNote}>Generate Draft</button>
<button type="button" className="drawer-secondary" onClick={onCopyNote}>Copy</button>
<button type="button" className="drawer-secondary" onClick={onExportNote}>Export MD</button>
</div>
<div className="drawer-meta inline">
<span>{noteValue.length} chars</span>
<span>Autosaved locally</span>
<span>{Object.keys(notes).length} notes</span>
</div>
<div className="note-archive">
<strong>Archive</strong>
{noteEntries.length === 0 ? (
<p className="empty-state">No archived notes yet.</p>
) : (
noteEntries.slice(0, 8).map(([key, value]) => {
const [cellId, organelleId] = key.split(':')
const noteCell = findCell(allCells, cellId)
const noteDetail = getOrganelleDetail(cellId, organelleId, customCells)
return (
<button
key={key}
type="button"
className={key === noteKey ? 'note-archive-row active' : 'note-archive-row'}
onClick={() => {
onSelectCell(cellId)
onSelectOrganelle(organelleId)
}}
>
<span>
<strong>{noteCell.name} / {noteDetail.title}</strong>
<small>{value.slice(0, 90)}</small>
</span>
</button>
)
})
)}
</div>
</div>
)
}
if (activePanel === 'Logs') {
const entries = serverLogs?.entries || []
return (
<div className="drawer-content">
<div className="settings-health">
<div>
<strong>Diagnostic Logs</strong>
<small>{serverLogs?.file || '.logs/3d-model-studio-api.log'} · {entries.length} entries</small>
</div>
<button type="button" className="drawer-secondary" onClick={onRefreshServerLogs}>
<RefreshCw size={13} />
Refresh
</button>
{serverLogs?.error && <p className="empty-state">{serverLogs.error}</p>}
<div className="drawer-actions">
<button type="button" className="drawer-primary" onClick={onExportDiagnostics}>Export Diagnostics</button>
<button type="button" className="drawer-secondary" onClick={onRefreshApiHealth}>Check API</button>
</div>
</div>
<div className="log-list">
{entries.length === 0 ? (
<p className="empty-state">No server log entries yet.</p>
) : (
entries.slice().reverse().map((entry, index) => (
<article key={`${entry.ts}-${entry.requestId || index}`} className={`log-row ${entry.level || 'info'}`}>
<div>
<strong>{entry.event || 'log.event'}</strong>
<small>{entry.ts ? formatDate(entry.ts) : 'unknown time'} · {entry.requestId || 'no request id'}</small>
</div>
<code>{formatLogSummary(entry)}</code>
</article>
))
)}
</div>
<div className="history-panel">
<div className="project-manager-head">
<div>
<strong>Frontend Generation History</strong>
<small>{generationHistory.length} local generation records.</small>
</div>
<button type="button" className="drawer-secondary" disabled={generationHistory.length === 0} onClick={onClearGenerationHistory}>Clear</button>
</div>
{generationHistory.length === 0 ? (
<p className="empty-state">No frontend generation history yet.</p>
) : (
<div className="history-list">
{generationHistory.slice(0, 10).map((item) => (
<button key={item.id} type="button" className={`history-row ${item.status}`} onClick={() => onSelectCell(item.cellId)}>
<span>
<strong>{item.cellName || item.cellId}</strong>
<small>{item.provider} · {item.status} · {formatDuration(item.durationMs)}</small>
</span>
<small>{formatDate(item.finishedAt || item.startedAt)}</small>
</button>
))}
</div>
)}
</div>
</div>
)
}
if (activePanel === 'Settings') {
return (
<div className="drawer-content settings-list">
<label className="settings-row">
<span>
<strong>Cross-Section</strong>
<small>Keep the cutaway view enabled.</small>
</span>
<input type="checkbox" checked={crossSection} onChange={(event) => onSetCrossSection(event.target.checked)} />
</label>
<div className="settings-row">
<span>
<strong>Render Quality</strong>
<small>Balanced is faster; high uses denser DPR.</small>
</span>
<div className="segmented">
{['balanced', 'high'].map((quality) => (
<button
key={quality}
type="button"
className={settings.quality === quality ? 'active' : ''}
onClick={() => onUpdateSettings({ ...settings, quality })}
>
{quality}
</button>
))}
</div>
</div>
<label className="settings-row">
<span>
<strong>Default Generation</strong>
<small>Used by the upload button before picking a file.</small>
</span>
<select
className="settings-select"
value={settings.generationMode}
onChange={(event) => onUpdateSettings({ ...settings, generationMode: event.target.value })}
>
{GENERATION_MODE_OPTIONS.map((option) => (
<option key={option.id} value={option.id}>{option.label}</option>
))}
</select>
</label>
<div className="settings-row">
<span>
<strong>Screenshot Size</strong>
<small>Exports a larger PNG from the WebGL canvas.</small>
</span>
<div className="segmented segmented-three">
{SCREENSHOT_SCALE_OPTIONS.map((option) => (
<button
key={option.id}
type="button"
className={settings.screenshotScale === option.id ? 'active' : ''}
onClick={() => onUpdateSettings({ ...settings, screenshotScale: option.id })}
>
{option.label}
</button>
))}
</div>
</div>
<div className="settings-row">
<span>
<strong>Language</strong>
<small>Stores the preferred UI language for the workspace.</small>
</span>
<div className="segmented">
{LANGUAGE_OPTIONS.map((option) => (
<button
key={option.id}
type="button"
className={settings.language === option.id ? 'active' : ''}
onClick={() => onUpdateSettings({ ...settings, language: option.id })}
>
{option.label}
</button>
))}
</div>
</div>
<label className="settings-row">
<span>
<strong>Compact UI</strong>
<small>Slightly tighter panels for smaller screens.</small>
</span>
<input type="checkbox" checked={settings.compactUi} onChange={(event) => onUpdateSettings({ ...settings, compactUi: event.target.checked })} />
</label>
<label className="settings-row">
<span>
<strong>Fal Model</strong>
<small>Used when the Fal or Auto provider reaches Fal.</small>
</span>
<select
className="settings-select"
value={settings.falModelId}
onChange={(event) => onUpdateSettings({ ...settings, falModelId: event.target.value })}
>
{FAL_MODEL_OPTIONS.map((option) => (
<option key={option.id} value={option.id} title={option.description}>{option.label}</option>
))}
</select>
</label>
<div className="settings-health">
<div>
<strong>API Health</strong>
<small>{apiHealth?.checkedAt ? `Checked ${formatDate(apiHealth.checkedAt)}` : 'Not checked yet'}</small>
</div>
<button type="button" className="drawer-secondary" onClick={onRefreshApiHealth}>
<RefreshCw size={13} />
Refresh
</button>
{apiHealth?.error ? (
<p className="empty-state">{apiHealth.error}</p>
) : (
<div className="health-grid">
{Object.entries(apiHealth?.providers || {}).map(([id, provider]) => (
<span key={id} className={provider.configured ? 'healthy' : 'missing'}>
<strong>{id}</strong>
<small>{provider.configured ? 'configured' : 'missing key/server'}</small>
</span>
))}
</div>
)}
</div>
<div className="drawer-actions">
<button type="button" className="drawer-secondary" onClick={onClearWorkspaceCache}>Clear Cache</button>
<button type="button" className="drawer-secondary danger" onClick={onResetWorkspace}>Reset Data</button>
</div>
</div>
)
}
if (activePanel === 'Compare') {
return (
<div className="drawer-content">
<div className="compare-drawer-grid">
{[cell, compare].map((item) => {
const itemProfile = getCellProfile(item.id, customCells)
return (
<div key={item.id} className="compare-card">
<CellThumb cell={item} selected={item.id === selectedCell} />
<strong>{item.name}</strong>
<small>{itemProfile.summary}</small>
</div>
)
})}
</div>
<p className="drawer-copy">{profile.comparison}</p>
<div className="cell-chip-grid">
{allCells.filter((item) => item.id !== selectedCell).map((item) => (
<button key={item.id} type="button" className={item.id === compareCell ? 'active' : ''} onClick={() => onSetCompareCell(item.id)}>
{item.name.replace(' Cell', '')}
</button>
))}
</div>
<div className="drawer-actions">
<button type="button" className="drawer-primary" onClick={() => onSelectCell(compareCell)}>Open Compared Model</button>
<button type="button" className="drawer-secondary" onClick={() => onSetCompareCell(profile.compareTarget)}>Reset Target</button>
</div>
</div>
)
}
const modelUrl = getModelUrl(cell)
const latestHistory = generationHistory.slice(0, 6)
return (
<div className="drawer-content">
<div className="profile-stats">
<span><strong>{allCells.length}</strong><small>models</small></span>
<span><strong>{galleryItems.length}</strong><small>saved</small></span>
<span><strong>{generationHistory.length}</strong><small>runs</small></span>
</div>
<div className="model-inspector">
<div>
<strong>Model Inspector</strong>
<small>{cell.name} · {getQualityLabel(cell)}</small>
</div>
<dl>
<dt>Source</dt>
<dd>{getModelSource(cell)}</dd>
<dt>Provider</dt>
<dd>{cell.generation?.provider || 'built-in'}</dd>
<dt>Status</dt>
<dd>{cell.generation?.status || 'interactive'}</dd>
<dt>Model URL</dt>
<dd>{modelUrl || 'procedural scene'}</dd>
<dt>Task</dt>
<dd>{cell.generation?.taskId || 'none'}</dd>
</dl>
<div className="drawer-actions">
<button type="button" className="drawer-secondary" disabled={!modelUrl} onClick={() => onCopyText(modelUrl, 'Model URL copied')}>Copy URL</button>
<button type="button" className="drawer-primary" disabled={!cell.custom || !cell.imageUrl} onClick={() => onRunProviderCompare(cell.id)}>Provider Compare</button>
</div>
</div>
<div className="project-manager">
<div className="project-manager-head">
<div>
<strong>Projects</strong>
<small>IndexedDB snapshots of the full workspace.</small>
</div>
<button type="button" className="drawer-primary" onClick={onSaveProject}>Save Project</button>
</div>
{projects.length === 0 ? (
<p className="empty-state">No saved projects yet.</p>
) : (
<div className="project-list">
{projects.map((project) => (
<article key={project.id} className="project-row">
{project.thumbnailUrl ? <img src={project.thumbnailUrl} alt={`${project.name} project thumbnail`} /> : <CellThumb cell={cell} />}
<div>
<strong>{project.name}</strong>
<small>{project.summary || '3D Model Studio workspace'} · {formatDate(project.savedAt)}</small>
</div>
<div className="project-actions">
<button type="button" onClick={() => onLoadProject(project.id)}>Load</button>
<button type="button" onClick={() => onExportProject(project)}>
<Download size={12} />
</button>
<button type="button" onClick={() => onDeleteProject(project.id)}>
<Trash2 size={12} />
</button>
</div>
</article>
))}
</div>
)}
</div>
<div className="history-panel">
<div className="project-manager-head">
<div>
<strong>Generation History</strong>
<small>Provider, duration, result, and retry context.</small>
</div>
<button type="button" className="drawer-secondary" disabled={generationHistory.length === 0} onClick={onClearGenerationHistory}>Clear</button>
</div>
{latestHistory.length === 0 ? (
<p className="empty-state">No generation runs yet.</p>
) : (
<div className="history-list">
{latestHistory.map((item) => (
<button key={item.id} type="button" className={`history-row ${item.status}`} onClick={() => onSelectCell(item.cellId)}>
<span>
<strong>{item.cellName || item.cellId}</strong>
<small>{item.provider} · {item.status} · {formatDuration(item.durationMs)}</small>
</span>
<small>{formatDate(item.finishedAt || item.startedAt)}</small>
</button>
))}
</div>
)}
</div>
<p className="drawer-copy">Pinned part: {savedFavorite}</p>
<p className="drawer-copy">Source: {profile.occurs}</p>
</div>
)
}
return (
<motion.section className={`workspace-drawer drawer-${String(activePanel).toLowerCase()}`} initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.18 }}>
<header>
<div>
<strong>{activePanel}</strong>
<span>{WORKSPACE_PANELS[activePanel]}</span>
</div>
<button type="button" onClick={onClose} aria-label="Close panel">
<X size={15} />
</button>
</header>
<div className="drawer-meta">
<span>{cell.name}</span>
<span>{detail.title}</span>
<span>Viewer ready</span>
</div>
{renderContent()}
</motion.section>
)
}
+66
View File
@@ -0,0 +1,66 @@
export const SETTINGS_STORAGE_KEY = 'bio-demo-settings'
export const GALLERY_STORAGE_KEY = 'bio-demo-gallery'
export const GENERATION_HISTORY_STORAGE_KEY = 'bio-demo-generation-history'
export const NOTES_STORAGE_KEY = 'bio-demo-notes'
export const PROJECT_FALLBACK_STORAGE_KEY = 'bio-demo-projects'
const VITE_ENV = import.meta.env || {}
export const SETTINGS_STORAGE_VERSION = 5
export const UI_STATE_STORAGE_KEY = 'bio-demo-ui-state'
export const UI_STATE_STORAGE_VERSION = 1
export const FAL_MODEL_OPTIONS = [
{ id: 'fal-ai/hunyuan3d/v2', label: 'Hunyuan3D v2', description: 'Tencent Hunyuan3D v2 through Fal.' },
{ id: 'fal-ai/trellis', label: 'TRELLIS', description: 'Image-to-3D with textured mesh output.' },
{ id: 'fal-ai/triposr', label: 'TripoSR', description: 'Fast image reconstruction through Fal.' },
{ id: 'tripo3d/tripo/v2.5/image-to-3d', label: 'Tripo3D v2.5', description: 'Fal-hosted Tripo3D image-to-3D.' },
{ id: 'fal-ai/hyper3d/rodin', label: 'Hyper3D Rodin', description: 'Fal-hosted Rodin image-to-3D.' },
]
export const FAL_MODEL_IDS = new Set(FAL_MODEL_OPTIONS.map((option) => option.id))
export const DEFAULT_FAL_MODEL = FAL_MODEL_OPTIONS[0].id
export const DEFAULT_SETTINGS = {
quality: 'balanced',
compactUi: false,
generationProvider: 'rodin',
generationMode: 'rodin',
falModelId: DEFAULT_FAL_MODEL,
screenshotScale: 2,
language: 'en',
settingsVersion: SETTINGS_STORAGE_VERSION,
}
export const SCREENSHOT_SCALE_OPTIONS = [
{ id: 1, label: '1x' },
{ id: 2, label: '2x' },
{ id: 3, label: '3x' },
]
export const LANGUAGE_OPTIONS = [
{ id: 'en', label: 'English' },
{ id: 'zh', label: '中文' },
]
export const LANGUAGE_IDS = new Set(LANGUAGE_OPTIONS.map((option) => option.id))
export const CUSTOM_CELL_STORAGE_KEY = 'bio-demo-custom-cells'
export const MAX_PERSISTED_IMAGE_EDGE = 1280
export const COMPACT_PERSISTED_IMAGE_EDGE = 900
export const MAX_PERSISTED_IMAGE_CHARS = 3_200_000
export const MODEL_API_BASE = VITE_ENV.VITE_MODEL_API_BASE || VITE_ENV.VITE_TRIPO_API_BASE || 'http://127.0.0.1:8787'
export const GENERATION_POLL_INTERVAL_MS = 3500
export const GENERATION_TIMEOUT_MS = 8 * 60 * 1000
export const GENERATION_PROVIDER_OPTIONS = [
{ id: 'rodin', label: 'Hyper3D', description: 'Hyper3D Rodin cloud generation.' },
{ id: 'auto', label: 'Auto', description: 'Hyper3D first, then Tripo, Fal, Hunyuan, and JS Depth backup.' },
{ id: 'tripo', label: 'Tripo', description: 'Cloud generation.' },
{ id: 'fal', label: 'Fal', description: 'Fal queue with selectable 3D models.' },
{ id: 'hunyuan', label: 'Hunyuan', description: 'Local Hunyuan3D server.' },
]
export const GENERATION_PROVIDER_IDS = new Set(GENERATION_PROVIDER_OPTIONS.map((provider) => provider.id))
export const GENERATION_MODE_OPTIONS = [
{ id: 'rodin', label: 'Hyper3D', description: 'Hyper3D Rodin GLB generation.' },
{ id: 'tripo', label: 'Tripo', description: 'Cloud GLB generation.' },
{ id: 'fal', label: 'Fal', description: 'Fal.ai queue with selectable model.' },
{ id: 'hunyuan', label: 'Hunyuan', description: 'Local Hunyuan3D GLB generation.' },
{ id: 'cinematic', label: 'JS Depth', description: 'Browser-side image relief with layered PNG fallback.' },
{ id: 'auto', label: 'Auto', description: 'Hyper3D, Tripo, Fal, Hunyuan, then JS Depth fallback.' },
{ id: 'local', label: 'Local GLB', description: 'Import an existing GLB or GLTF file.' },
]
export const GENERATION_MODE_IDS = new Set(GENERATION_MODE_OPTIONS.map((mode) => mode.id))
+171
View File
@@ -0,0 +1,171 @@
import { CUSTOM_CELL_STORAGE_KEY } from '../config/appConfig.js'
import { loadStoredValue } from '../lib/storage.js'
import {
CELL_DETAIL_OVERRIDES,
CELL_PROFILES,
CELL_TYPES,
DEFAULT_ORGANELLE_BY_CELL,
KHRONOS_REFERENCE_CELLS,
ORGANELLES,
ORGANELLE_ORDER,
SEEDED_GENERATED_CELLS,
} from './cellData.js'
export function getStoredCustomCells() {
return loadStoredValue(CUSTOM_CELL_STORAGE_KEY, [])
}
export function getPrimaryCells(customCells = getStoredCustomCells()) {
const activeCustomCells = customCells.filter((cell) => cell.generation?.status !== 'failed')
const failedCustomCells = customCells.filter((cell) => cell.generation?.status === 'failed')
return [...activeCustomCells, ...SEEDED_GENERATED_CELLS, ...failedCustomCells, ...CELL_TYPES]
}
export function getAllCells(customCells = getStoredCustomCells()) {
return [...getPrimaryCells(customCells), ...KHRONOS_REFERENCE_CELLS]
}
export function getCell(cellId, customCells = getStoredCustomCells()) {
return getAllCells(customCells).find((cell) => cell.id === cellId) ?? CELL_TYPES[1]
}
export function getCustomCell(cellId, customCells = getStoredCustomCells()) {
return [...customCells, ...SEEDED_GENERATED_CELLS, ...KHRONOS_REFERENCE_CELLS].find((cell) => cell.id === cellId)
}
export function getModelCellId(cellId, customCells = getStoredCustomCells()) {
return getCustomCell(cellId, customCells)?.template ?? cellId
}
export function getCellProfile(cellId, customCells = getStoredCustomCells()) {
const customCell = getCustomCell(cellId, customCells)
if (customCell) {
const baseProfile = CELL_PROFILES[customCell.template] ?? CELL_PROFILES.animal
if (customCell.reference) {
return {
...baseProfile,
summary: customCell.referenceSummary,
comparison: `${customCell.name} is a Khronos glTF reference asset for inspecting material behavior and GLB loader compatibility, not a biological teaching model.`,
occurs: customCell.referenceSource,
organelles: baseProfile.organelles,
}
}
const hasGeneratedModel = Boolean(customCell.generation?.modelUrl)
const isCinematic = customCell.generation?.provider === 'cinematic'
return {
...baseProfile,
summary: isCinematic
? 'Browser-generated JS depth relief from the uploaded image. This is a visual fallback, not a real GLB mesh.'
: hasGeneratedModel
? 'AI-generated GLB from the uploaded image, loaded as an interactive WebGL model.'
: 'Uploaded source asset queued for image-to-3D generation. A procedural preview is used only while the GLB is unavailable.',
comparison: isCinematic
? 'This custom sample uses a browser-generated displacement mesh plus transparent depth slabs, not a GLB or full AI-generated mesh.'
: hasGeneratedModel
? 'This custom sample is loaded as a real generated GLB in the WebGL viewer.'
: 'This custom sample will use a generic procedural preview while generation is running.',
occurs: 'Uploaded by the user as a custom 3D model source.',
organelles: ORGANELLE_ORDER,
}
}
return CELL_PROFILES[cellId] ?? CELL_PROFILES['white-blood']
}
export function getAvailableOrganelleIds(cellId, customCells = getStoredCustomCells()) {
return getCellProfile(cellId, customCells).organelles ?? ORGANELLE_ORDER
}
export function getDefaultOrganelle(cellId, customCells = getStoredCustomCells()) {
const available = getAvailableOrganelleIds(cellId, customCells)
const preferred = DEFAULT_ORGANELLE_BY_CELL[cellId] ?? available[0]
return available.includes(preferred) ? preferred : available[0]
}
export function getOrganelleDetail(cellId, organelleId, customCells = getStoredCustomCells()) {
const customCell = getCustomCell(cellId, customCells)
const detailCellId = customCell ? null : cellId
return {
...ORGANELLES[organelleId],
...(detailCellId ? CELL_DETAIL_OVERRIDES[detailCellId]?.[organelleId] ?? {} : {}),
}
}
export function getGenerationPrompt(cell) {
if (cell.intelligence?.generationPrompt) {
return [
cell.intelligence.generationPrompt,
'Make it a single integrated object, not a flat relief, not a display base.',
'Preserve the recognizable silhouette, major volumes, surface details, and material separation.',
'Style: polished interactive 3D studio asset, clean PBR materials, soft studio lighting.',
].join(' ')
}
return [
`A high quality 3D model generated from the uploaded reference image named ${cell.name}.`,
'Make it a single integrated object, not a flat relief, not a display base.',
'Preserve the recognizable silhouette, major volumes, surface details, and material separation.',
'Style: polished interactive 3D studio asset, clean PBR materials, soft studio lighting.',
].join(' ')
}
export function getGeneratedModelUrl(cell) {
return cell.custom ? cell.generation?.modelUrl || '' : ''
}
export function cleanFileName(fileName) {
return fileName.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
}
export function inferCellTemplate(fileName) {
const name = fileName.toLowerCase()
if (name.includes('plant') || name.includes('leaf') || name.includes('chloroplast')) return 'plant'
if (name.includes('bacteria') || name.includes('bacillus') || name.includes('microbe')) return 'bacteria'
if (name.includes('neuron') || name.includes('nerve')) return 'neuron'
if (name.includes('muscle') || name.includes('fiber')) return 'muscle'
if (name.includes('epithelial') || name.includes('tissue')) return 'epithelial'
if (name.includes('blood') || name.includes('immune') || name.includes('wbc')) return 'white-blood'
return 'animal'
}
export function isLocalModelFile(file) {
return /\.(?:glb|gltf)$/i.test(file.name)
}
export function createCustomCell(fileName, imageUrl, options = {}) {
const template = inferCellTemplate(fileName)
const base = getCell(template)
const name = cleanFileName(fileName) || 'Uploaded Model'
const provider = options.provider || 'tripo'
return {
id: `custom-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
name: name.length > 20 ? `${name.slice(0, 20)}...` : name,
fullName: name,
sourceFileName: fileName,
type: options.type || 'Uploaded 3D Asset',
accent: base.accent,
custom: true,
template,
imageUrl,
thumbnailUrl: options.thumbnailUrl || '',
generation: {
provider,
requestedProvider: options.requestedProvider || provider,
status: options.status || 'queued',
taskId: options.taskId || '',
modelUrl: options.modelUrl || '',
rawModelUrl: options.rawModelUrl || '',
message: options.message || 'Waiting for image-to-3D generation.',
},
}
}
export function getUploadPreviewFromCustomCells(customCells) {
const latest = customCells.find((cell) => cell.custom)
if (!latest) return null
return { name: latest.name, url: latest.imageUrl || latest.thumbnailUrl || '' }
}
+339
View File
@@ -0,0 +1,339 @@
import plantCellRender from '../assets/cell-plant-render.png'
export const CELL_TYPES = [
{ id: 'plant', name: 'Plant Specimen', type: 'Starter Asset', accent: '#82b366' },
{ id: 'white-blood', name: 'Immune Specimen', type: 'Starter Asset', accent: '#7e6edb' },
{ id: 'neuron', name: 'Neuron Specimen', type: 'Starter Asset', accent: '#8b5cf6' },
{ id: 'epithelial', name: 'Tissue Specimen', type: 'Starter Asset', accent: '#e07a7a' },
{ id: 'bacteria', name: 'Microbe Specimen', type: 'Starter Asset', accent: '#5fbf9f' },
{ id: 'animal', name: 'Organic Specimen', type: 'Starter Asset', accent: '#459ccf' },
{ id: 'muscle', name: 'Fiber Specimen', type: 'Starter Asset', accent: '#d25762' },
]
export const SEEDED_GENERATED_CELLS = [
{
id: 'tripo-epithelial-test',
name: 'Tripo Tissue Test',
type: 'Cached AI Asset',
accent: '#e07a7a',
custom: true,
template: 'epithelial',
imageUrl: '/epithelial_cell_3d_tripo_input.png',
generation: {
provider: 'tripo',
status: 'success',
taskId: 'dc44beb1-e1a1-4650-9337-fbe418b7b154',
modelUrl: '/generated-models/tripo-epithelial-cell-test.glb',
rawModelUrl: '',
message: 'Cached GLB from the verified Tripo epithelial test run.',
},
},
{
id: 'tripo-plant-test',
name: 'Tripo Plant Test',
type: 'Cached AI Asset',
accent: '#82b366',
custom: true,
template: 'plant',
imageUrl: plantCellRender,
generation: {
provider: 'tripo',
status: 'success',
taskId: '1db80a91-e202-4494-b17b-147de74cae81',
modelUrl: '/generated-models/tripo-plant-cell-test.glb',
rawModelUrl: '',
message: 'Cached GLB from the verified Tripo test run.',
},
},
]
export const KHRONOS_REFERENCE_CELLS = [
{
id: 'khronos-transmission-test',
name: 'Transmission Test',
type: 'Khronos PBR Reference',
accent: '#72a4bf',
custom: true,
reference: true,
template: 'animal',
imageUrl: 'https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/main/2.0/TransmissionTest/screenshot/screenshot_large.png',
referenceSummary: 'Official Khronos glTF sample for KHR_materials_transmission. Useful for tuning transparent membranes, glassy shells, and opacity interactions.',
referenceLicense: 'CC0, Adobe via Khronos glTF Sample Models',
referenceSource: 'https://github.com/KhronosGroup/glTF-Sample-Models/tree/main/2.0/TransmissionTest',
generation: {
provider: 'reference',
requestedProvider: 'reference',
status: 'success',
taskId: 'khronos-transmission-test',
modelUrl: 'https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/main/2.0/TransmissionTest/glTF-Binary/TransmissionTest.glb',
rawModelUrl: '',
message: 'Remote Khronos GLB reference for transparent material behavior.',
},
},
{
id: 'khronos-transmission-roughness',
name: 'Transmission Roughness',
type: 'Khronos PBR Reference',
accent: '#8eb4cf',
custom: true,
reference: true,
template: 'animal',
imageUrl: 'https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/main/2.0/TransmissionRoughnessTest/screenshot/screenshot-large.png',
referenceSummary: 'Official Khronos glTF sample for transmission, IOR, roughness, and volume. Useful for soft translucent shells and layered material haze.',
referenceLicense: 'CC-BY 4.0, Ed Mackey / Analytical Graphics via Khronos glTF Sample Models',
referenceSource: 'https://github.com/KhronosGroup/glTF-Sample-Models/tree/main/2.0/TransmissionRoughnessTest',
generation: {
provider: 'reference',
requestedProvider: 'reference',
status: 'success',
taskId: 'khronos-transmission-roughness',
modelUrl: 'https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/main/2.0/TransmissionRoughnessTest/glTF-Binary/TransmissionRoughnessTest.glb',
rawModelUrl: '',
message: 'Remote Khronos GLB reference for IOR and translucent roughness.',
},
},
{
id: 'khronos-mosquito-amber',
name: 'Mosquito In Amber',
type: 'Khronos Bio Reference',
accent: '#d18a42',
custom: true,
reference: true,
template: 'bacteria',
imageUrl: 'https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/main/2.0/MosquitoInAmber/screenshot/screenshot.jpg',
referenceSummary: 'Biological specimen in a transparent amber volume. Useful as a target for organic detail plus translucent material presentation.',
referenceLicense: 'CC-BY 4.0, Loic Norgeot / Geoffrey Marchal / Sketchfab via Khronos glTF Sample Models',
referenceSource: 'https://github.com/KhronosGroup/glTF-Sample-Models/tree/main/2.0/MosquitoInAmber',
generation: {
provider: 'reference',
requestedProvider: 'reference',
status: 'success',
taskId: 'khronos-mosquito-amber',
modelUrl: 'https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/main/2.0/MosquitoInAmber/glTF-Binary/MosquitoInAmber.glb',
rawModelUrl: '',
message: 'Remote Khronos biological GLB reference. This model is larger and may take longer to load.',
},
},
]
export const ORGANELLES = {
nucleus: {
label: 'Core Volume',
title: 'Core Volume',
subtitle: 'Main internal mass',
size: 'Dominant inner volume',
location: 'Near the visual center',
visible: 'Visible in inspection mode',
note: 'The core volume is the main readable mass of the model. It helps the viewer understand scale, silhouette, and depth.',
accent: '#7b4bb4',
},
lysosome: {
label: 'Accent Features',
title: 'Accent Features',
subtitle: 'Secondary forms and visual anchors',
size: 'Small supporting features',
location: 'Distributed across the model',
visible: 'Visible as detail clusters',
note: 'Accent features make the generated object feel less flat. They are useful for checking whether the source image produced meaningful 3D detail.',
accent: '#8d58b8',
},
mitochondria: {
label: 'Material Bands',
title: 'Material Bands',
subtitle: 'Color and surface-material zones',
size: 'Medium surface groups',
location: 'Across the visible surface',
visible: 'Visible through material contrast',
note: 'Material bands show whether the model preserves color separation, roughness, and recognisable surface regions from the reference.',
accent: '#df7046',
},
membrane: {
label: 'Surface Shell',
title: 'Surface Shell',
subtitle: 'Outer silhouette and contact layer',
size: 'Full object boundary',
location: 'Model perimeter',
visible: 'Always visible',
note: 'The surface shell is the most important quality signal. If the silhouette reads correctly, the model will work better for screenshots and demos.',
accent: '#7aa4bf',
},
granules: {
label: 'Detail Points',
title: 'Detail Points',
subtitle: 'Small high-frequency geometry',
size: 'Fine detail marks',
location: 'Surface and recessed areas',
visible: 'Visible when zoomed in',
note: 'Detail points reveal whether the generator created real dimensional cues rather than a smooth blob or flat card.',
accent: '#5b82c4',
},
}
export const ORGANELLE_ORDER = ['nucleus', 'lysosome', 'mitochondria', 'membrane', 'granules']
export const MICROSCOPE_IMAGES = [
{ label: 'Studio Preview', tone: 'light', note: 'Clean presentation view for screenshots.' },
{ label: 'Texture Pass', tone: 'purple', note: 'Material and color separation preview.' },
{ label: 'Depth Preview', tone: 'mono', note: 'Shape and relief readability preview.' },
]
export const WORKSPACE_PANELS = {
Gallery: 'Saved render angles, thumbnails, and exported presentation shots.',
Library: 'Starter models, generated assets, local imports, and reference GLB files.',
Notebooks: 'Observation notes linked to the selected asset and inspection part.',
Logs: 'Diagnostics, API request logs, and generation troubleshooting.',
Settings: 'Viewer quality, provider defaults, screenshot size, and export preferences.',
Compare: 'Side-by-side model comparison for shape, material, and generation quality.',
Profile: 'Current workspace: 3D Model Studio.',
}
export const CELL_PROFILES = {
plant: {
summary: 'Rigid wall, large vacuole, chloroplast-like structures, Golgi stacks, and a clear nucleus.',
occurs: 'Leaves, stems, roots, and photosynthetic tissue.',
comparison: 'Has a rigid wall and chloroplast-like organelles; animal cells do not.',
compareTarget: 'animal',
organelles: ['membrane', 'nucleus', 'mitochondria', 'granules'],
},
'white-blood': {
summary: 'Soft immune cell with lobed nucleus, many lysosomes, granules, and deformable membrane.',
occurs: 'Blood, lymph, and inflamed tissue.',
comparison: 'More mobile and granular than epithelial cells; built for immune response.',
compareTarget: 'epithelial',
organelles: ['lysosome', 'nucleus', 'mitochondria', 'membrane', 'granules'],
},
neuron: {
summary: 'Compact soma with branching dendrite and axon-like extensions for signal routing.',
occurs: 'Brain, spinal cord, and peripheral nerves.',
comparison: 'Long membrane extensions dominate the shape; most other cells stay compact.',
compareTarget: 'muscle',
organelles: ['membrane', 'nucleus', 'mitochondria', 'granules'],
},
epithelial: {
summary: 'Sheet-like tissue cell with apical ridges, junction cues, membrane boundaries, and nucleus.',
occurs: 'Skin, ducts, organ linings, and protective surfaces.',
comparison: 'Designed for barrier tissue, unlike free-moving white blood cells.',
compareTarget: 'white-blood',
organelles: ['membrane', 'nucleus', 'mitochondria', 'granules'],
},
bacteria: {
summary: 'Prokaryotic capsule with nucleoid DNA, ribosome dots, pili, and a flagellum cue.',
occurs: 'Soil, water, gut flora, skin, and many environmental surfaces.',
comparison: 'No nucleus or membrane-bound organelles; the DNA sits in a nucleoid region.',
compareTarget: 'animal',
organelles: ['membrane', 'granules'],
},
animal: {
summary: 'Flexible eukaryotic cell with nucleus, mitochondria, vesicles, and soft membrane.',
occurs: 'Organs, connective tissue, blood-related tissues, and cultured samples.',
comparison: 'Lacks the rigid wall shown in plant cells.',
compareTarget: 'plant',
organelles: ['membrane', 'nucleus', 'mitochondria', 'lysosome', 'granules'],
},
muscle: {
summary: 'Elongated fiber-like cell with striation cues and extra mitochondria for contraction.',
occurs: 'Skeletal muscle, cardiac tissue, and contractile tissue samples.',
comparison: 'Elongated and energy-heavy compared with round animal cells.',
compareTarget: 'neuron',
organelles: ['membrane', 'nucleus', 'mitochondria', 'granules'],
},
}
export const DEFAULT_ORGANELLE_BY_CELL = {
plant: 'membrane',
'white-blood': 'lysosome',
neuron: 'nucleus',
epithelial: 'membrane',
bacteria: 'granules',
animal: 'nucleus',
muscle: 'mitochondria',
}
export const CELL_DETAIL_OVERRIDES = {
plant: {
nucleus: {
subtitle: 'The command center',
size: '5-10 um in diameter',
location: 'Usually central',
visible: 'Yes',
note: 'The nucleus is surrounded by a double membrane called the nuclear envelope, which contains pores that regulate the movement of molecules in and out.',
funFact: 'The nucleus was one of the first cell structures discovered.',
},
membrane: {
title: 'Cell Wall',
subtitle: 'Rigid outer support',
size: 'About 0.1-10 um thick',
location: 'Outer boundary',
visible: 'Yes',
note: 'Plant cells have a rigid wall outside the membrane. It gives the cell shape and helps resist pressure from the large central vacuole.',
funFact: 'Cellulose fibers make plant cell walls strong and flexible.',
},
mitochondria: {
note: 'Mitochondria convert stored sugars into usable energy for growth, repair, and transport inside the plant cell.',
funFact: 'Plant cells have both mitochondria and chloroplasts.',
},
granules: {
title: 'Golgi Apparatus',
subtitle: 'Packaging and transport',
note: 'The Golgi modifies, sorts, and packages proteins and lipids before they move to their next destination.',
funFact: 'Golgi stacks look like folded ribbons in many educational renders.',
},
},
'white-blood': {
lysosome: {
note: 'White blood cells carry many lysosomes because they digest captured particles and damaged material during immune response.',
funFact: 'The clustered purple granules are emphasized here so they remain readable while rotating.',
},
nucleus: {
note: 'The lobed nucleus is a key visual feature of many immune cells and helps the cell deform through narrow tissue gaps.',
},
},
neuron: {
membrane: {
title: 'Axon and Dendrites',
subtitle: 'Signal-routing branches',
location: 'Extending from the soma',
note: 'Neurons depend on long membrane extensions to receive and transmit electrical signals across large distances.',
funFact: 'The branching structure matters more visually than a perfectly round cell body.',
},
},
epithelial: {
membrane: {
title: 'Apical Surface',
subtitle: 'Barrier and contact layer',
location: 'Tissue-facing edge',
note: 'Epithelial cells form sheets. The surface ridges and junction lines make that tissue architecture visible.',
},
},
bacteria: {
granules: {
title: 'Nucleoid and Ribosomes',
subtitle: 'Prokaryotic core material',
size: 'Not membrane bound',
location: 'Central cytoplasm',
note: 'Bacteria do not have a nucleus. The blue DNA coil and small ribosome dots represent the prokaryotic interior.',
funFact: 'The flagellum and pili are exaggerated for readability in the 3D viewer.',
},
},
animal: {
nucleus: {
note: 'Animal cells are shown with a softer membrane, central nucleus, mitochondria, and transport structures without a rigid wall.',
},
},
muscle: {
mitochondria: {
note: 'Muscle fibers contain many mitochondria because contraction needs sustained ATP production.',
funFact: 'The stripe pattern is a simplified sarcomere cue, not a literal molecular model.',
},
},
}
export const CELL_BODY = {
plant: { color: '#b8d983', scale: [1.38, 1.04, 0.76], kind: 'box' },
'white-blood': { color: '#c9d3e6', scale: [1.34, 1.18, 0.92], kind: 'sphere' },
neuron: { color: '#d8c6ff', scale: [0.78, 0.68, 0.58], kind: 'sphere' },
epithelial: { color: '#efb4a6', scale: [1.22, 0.92, 0.52], kind: 'box' },
bacteria: { color: '#8ed9bc', scale: [0.9, 1, 0.56], kind: 'capsule' },
animal: { color: '#b8dcf2', scale: [1.18, 1.08, 0.9], kind: 'sphere' },
muscle: { color: '#e78a94', scale: [0.82, 1.1, 0.48], kind: 'capsule' },
}
+69
View File
@@ -0,0 +1,69 @@
import { CUSTOM_CELL_STORAGE_KEY } from '../config/appConfig.js'
import { storeValue } from '../lib/storage.js'
export function persistCustomCells(cells) {
if (storeValue(CUSTOM_CELL_STORAGE_KEY, cells)) {
return { cells, stored: true, compacted: false }
}
const withoutGeneratedPreviews = compactCustomCellsForStorage(cells, 'generated-previews')
if (storeValue(CUSTOM_CELL_STORAGE_KEY, withoutGeneratedPreviews)) {
return { cells: withoutGeneratedPreviews, stored: true, compacted: true }
}
const withoutAllPreviews = compactCustomCellsForStorage(cells, 'all-previews')
if (storeValue(CUSTOM_CELL_STORAGE_KEY, withoutAllPreviews)) {
return { cells: withoutAllPreviews, stored: true, compacted: true }
}
const minimal = compactCustomCellsForStorage(cells, 'minimal')
return {
cells: minimal,
stored: storeValue(CUSTOM_CELL_STORAGE_KEY, minimal),
compacted: true,
}
}
export function compactCustomCellsForStorage(cells, mode) {
let changed = false
const compacted = cells.map((cell) => {
if (mode === 'generated-previews' && !canDropPreview(cell)) return cell
if (mode !== 'minimal' && !cell.imageUrl && cell.previewDropped) return cell
const next = {
...cell,
imageUrl: '',
previewDropped: true,
}
if (mode !== 'minimal') {
changed = true
return next
}
const nextMessage = shortenMessage(next.generation?.message)
if (!cell.imageUrl && cell.previewDropped && !next.generation?.rawModelUrl && next.generation?.message === nextMessage) return cell
changed = true
return {
...next,
generation: {
...next.generation,
rawModelUrl: '',
message: shortenMessage(next.generation?.message),
},
}
})
return changed ? compacted : cells
}
function canDropPreview(cell) {
const generation = cell.generation || {}
return Boolean(generation.modelUrl) || ['success', 'local'].includes(String(generation.status || '').toLowerCase())
}
function shortenMessage(message) {
const value = String(message || '')
return value.length > 180 ? `${value.slice(0, 177)}...` : value
}
+67
View File
@@ -0,0 +1,67 @@
import {
DEFAULT_SETTINGS,
FAL_MODEL_IDS,
GENERATION_MODE_IDS,
GENERATION_PROVIDER_IDS,
LANGUAGE_IDS,
SCREENSHOT_SCALE_OPTIONS,
SETTINGS_STORAGE_VERSION,
UI_STATE_STORAGE_VERSION,
} from '../config/appConfig.js'
import { MICROSCOPE_IMAGES } from './cellData.js'
import { getCellProfile } from './cellCatalog.js'
export function normalizeSettings(value) {
const stored = value && typeof value === 'object' ? value : {}
const next = { ...DEFAULT_SETTINGS, ...stored }
const storedMode = stored.generationMode || stored.generationProvider
if (stored.settingsVersion !== SETTINGS_STORAGE_VERSION) {
next.generationProvider = GENERATION_PROVIDER_IDS.has(stored.generationProvider) ? stored.generationProvider : DEFAULT_SETTINGS.generationProvider
next.generationMode = GENERATION_MODE_IDS.has(storedMode) ? storedMode : DEFAULT_SETTINGS.generationMode
next.falModelId = FAL_MODEL_IDS.has(stored.falModelId) ? stored.falModelId : DEFAULT_SETTINGS.falModelId
next.screenshotScale = normalizeScreenshotScale(stored.screenshotScale)
next.language = LANGUAGE_IDS.has(stored.language) ? stored.language : DEFAULT_SETTINGS.language
}
if (!GENERATION_PROVIDER_IDS.has(next.generationProvider)) {
next.generationProvider = DEFAULT_SETTINGS.generationProvider
}
if (!GENERATION_MODE_IDS.has(next.generationMode)) {
next.generationMode = DEFAULT_SETTINGS.generationMode
}
if (!FAL_MODEL_IDS.has(next.falModelId)) {
next.falModelId = DEFAULT_SETTINGS.falModelId
}
next.screenshotScale = normalizeScreenshotScale(next.screenshotScale)
if (!LANGUAGE_IDS.has(next.language)) {
next.language = DEFAULT_SETTINGS.language
}
next.settingsVersion = SETTINGS_STORAGE_VERSION
return next
}
function normalizeScreenshotScale(value) {
const scale = Number(value)
return SCREENSHOT_SCALE_OPTIONS.some((option) => option.id === scale) ? scale : DEFAULT_SETTINGS.screenshotScale
}
export function normalizeUiState(value) {
const stored = value && typeof value === 'object' ? value : {}
const selectedMicroscope = MICROSCOPE_IMAGES.some((item) => item.label === stored.selectedMicroscope)
? stored.selectedMicroscope
: MICROSCOPE_IMAGES[0].label
return {
selectedCell: typeof stored.selectedCell === 'string' ? stored.selectedCell : 'plant',
selectedOrganelle: typeof stored.selectedOrganelle === 'string' ? stored.selectedOrganelle : 'nucleus',
selectedMicroscope,
compareCell: typeof stored.compareCell === 'string' ? stored.compareCell : getCellProfile('plant').compareTarget,
crossSection: typeof stored.crossSection === 'boolean' ? stored.crossSection : true,
favoriteKey: typeof stored.favoriteKey === 'string' ? stored.favoriteKey : '',
uiStateVersion: UI_STATE_STORAGE_VERSION,
}
}
+41
View File
@@ -0,0 +1,41 @@
:root {
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: #f8fafc;
background: #f7f8f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
width: 100%;
min-width: 320px;
min-height: 100%;
margin: 0;
background: #f7f8f6;
}
body {
overflow: hidden;
}
button,
input,
textarea,
select {
font: inherit;
}
@media (max-width: 980px) {
body {
overflow: auto;
}
}
+258
View File
@@ -0,0 +1,258 @@
export const ASSET_CATEGORIES = [
{
id: 'artifact',
label: 'Museum Artifact',
motionProfile: 'artifact',
sceneProfile: 'artifact',
keywords: ['artifact', 'bronze', 'gold mask', 'golden mask', 'mask', 'statue', 'relic', 'sanxingdui', '三星堆', '青铜', '金器', '金面', '黄金面具', '面具', '文物', '器物', '人像'],
strongKeywords: ['sanxingdui', '三星堆', 'bronze mask', 'gold mask', 'golden mask', '青铜人头像', '戴金面罩', '黄金面具'],
material: 'Metal, patina, carved relief, and aged surface detail',
scale: 'Reference-derived object scale',
inspectionFocus: 'silhouette, patina, relief, edge profile',
description: 'A museum-style artifact asset. The important visual signals are silhouette, surface relief, material aging, and readable symbolic details rather than mechanical precision.',
value: 'Works best as a slow inspection demo with side lighting, close orbit, and detail pauses on the face, edge profile, and weathered material transitions.',
tags: ['artifact', 'patina', 'relief detail', 'museum display', 'inspection'],
},
{
id: 'road',
label: 'Performance Vehicle',
motionProfile: 'road',
sceneProfile: 'road',
keywords: ['supercar', 'sports car', 'race car', 'ferrari', 'lamborghini', 'porsche', 'vehicle', 'automobile', 'car', 'truck', 'suv', 'motorcycle', '跑车', '赛车', '汽车', '法拉利', '兰博基尼', '保时捷', '卡车', '摩托'],
strongKeywords: ['supercar', 'sports car', 'race car', 'ferrari', 'lamborghini', 'porsche', '跑车', '赛车', '法拉利', '兰博基尼', '保时捷'],
material: 'Painted body panels, glass, rubber, alloy, and dark trim',
scale: 'Single vehicle asset',
inspectionFocus: 'stance, wheels, glass canopy, front profile',
description: 'A road-vehicle asset where stance, wheel placement, front profile, glass canopy, and body highlights decide whether the model feels believable.',
value: 'Use a low camera, road push-in, and front three-quarter framing. The demo should sell motion, gloss, and mass instead of treating it like a static specimen.',
tags: ['vehicle', 'road pass', 'low camera', 'paint gloss', 'showcase'],
},
{
id: 'vessel',
label: 'Naval Vessel',
motionProfile: 'vessel',
sceneProfile: 'vessel',
keywords: ['aircraft carrier', 'aircraft car', 'carrier', 'warship', 'destroyer', 'ship', 'vessel', 'naval', 'submarine', '航母', '航空母舰', '军舰', '驱逐舰', '舰', '船', '潜艇'],
strongKeywords: ['aircraft carrier', 'aircraft car', '航母', '航空母舰', 'warship', 'destroyer'],
material: 'Painted steel, deck surfaces, tower forms, antenna detail, and waterline mass',
scale: 'Large vessel asset',
inspectionFocus: 'hull length, deck plane, island tower, waterline',
description: 'A naval-vessel asset. The key read is the long hull, deck plane, island/superstructure, and heavy silhouette rather than small decorative parts.',
value: 'Use a slow side cruise, broad camera distance, water/wake cues, and a heavier pacing so the object does not feel like a small toy.',
tags: ['naval', 'hull', 'deck', 'waterline', 'slow cruise'],
},
{
id: 'aircraft',
label: 'Aircraft',
motionProfile: 'aircraft',
sceneProfile: 'aircraft',
keywords: ['fighter jet', 'fighter', 'airplane', 'aeroplane', 'aircraft', 'plane', 'jet', 'drone', 'helicopter', 'missile', '战斗机', '飞机', '歼', '轰炸机', '无人机', '直升机', '导弹'],
strongKeywords: ['fighter jet', 'fighter', 'airplane', 'aircraft', '战斗机', '飞机', '无人机'],
material: 'Painted fuselage, canopy glass, wing edges, intakes, and exhaust geometry',
scale: 'Single aircraft asset',
inspectionFocus: 'fuselage, wings, tail, canopy, engine areas',
description: 'An aircraft asset where the fuselage centerline, wings, tail, canopy, and engine areas must stay coherent from multiple angles.',
value: 'Use a flight-pass camera with banking, contrails, and forward drift. The demo should make direction and lift obvious.',
tags: ['aircraft', 'flight pass', 'banking', 'canopy', 'aero form'],
},
{
id: 'product',
label: 'Product Object',
motionProfile: 'product',
sceneProfile: 'product',
keywords: ['watch', 'phone', 'camera', 'shoe', 'bag', 'chair', 'lamp', 'bottle', 'headphone', 'jewelry', 'ring', '手表', '手机', '相机', '鞋', '包', '椅子', '灯', '瓶', '耳机', '戒指'],
strongKeywords: ['watch', 'phone', 'camera', 'shoe', 'handbag', '手表', '手机', '相机'],
material: 'Mixed product materials, edge highlights, texture breaks, and brand-like surface zones',
scale: 'Single product asset',
inspectionFocus: 'silhouette, material zones, recognizable feature layout',
description: 'A product asset. The model quality depends on whether the silhouette, primary material zones, and recognisable feature layout survived generation.',
value: 'Use a clean studio turntable, soft reflections, and short zoom pauses on the recognisable product features.',
tags: ['product', 'turntable', 'studio light', 'material zones', 'detail pause'],
},
{
id: 'specimen',
label: 'Organic Specimen',
motionProfile: 'specimen',
sceneProfile: 'specimen',
keywords: ['cell', 'biology', 'biological', 'organism', 'specimen', 'plant', 'neuron', 'bacteria', 'blood', 'epithelial', 'muscle', 'mosquito', '细胞', '生物', '植物', '神经', '细菌', '肌肉'],
strongKeywords: ['cell', 'biology', 'biological', 'specimen', '细胞', '生物'],
material: 'Soft translucent surfaces, organic volume, color-separated internal forms',
scale: 'Specimen-style asset',
inspectionFocus: 'overall volume, translucent surface, internal clusters',
description: 'An organic/specimen asset. The useful read is the overall volume, translucent surface, and clustered internal detail rather than exact biological accuracy.',
value: 'Use close orbit, clean rim light, and slower zooms. This works best as an educational inspection view.',
tags: ['specimen', 'organic', 'inspection orbit', 'soft volume', 'education'],
},
]
export const SCENE_PROFILES = {
road: {
id: 'road',
label: 'Road Showcase',
summary: 'Low road deck, moving lane marks, and front push-in camera.',
environment: 'road deck',
badges: ['low camera', 'moving lane', 'gloss read'],
},
aircraft: {
id: 'aircraft',
label: 'Sky Flight Pass',
summary: 'Bright sky volume, contrail streaks, and banked fly-by motion.',
environment: 'sky pass',
badges: ['banking', 'contrails', 'forward drift'],
},
vessel: {
id: 'vessel',
label: 'Naval Waterline',
summary: 'Water plane, broad wake, and slow side-tracking camera.',
environment: 'waterline',
badges: ['wake', 'side cruise', 'heavy scale'],
},
artifact: {
id: 'artifact',
label: 'Museum Turntable',
summary: 'Dark gallery stage, warm spotlights, and close material inspection.',
environment: 'museum plinth',
badges: ['spotlight', 'patina', 'close orbit'],
},
product: {
id: 'product',
label: 'Studio Turntable',
summary: 'Clean reflective studio floor, softboxes, and controlled product reveal.',
environment: 'studio sweep',
badges: ['turntable', 'reflection', 'detail pause'],
},
specimen: {
id: 'specimen',
label: 'Specimen Lab',
summary: 'Soft lab volume, microscope-style depth lines, and close orbit.',
environment: 'lab orbit',
badges: ['rim light', 'inspection', 'soft volume'],
},
}
export function getAssetIntelligence(cell = {}) {
const category = inferAssetCategory(cell)
return {
category,
scene: getSceneProfile(category.sceneProfile),
}
}
export function inferAssetCategory(cell = {}) {
const overrideId = normalizeCategoryOverride(cell.intelligence?.categoryId)
if (overrideId) return buildCategoryWithInsight(ASSET_CATEGORIES.find((rule) => rule.id === overrideId), cell.intelligence)
const primaryText = normalizeSearchText([
cell.id,
cell.fullName,
cell.sourceFileName,
cell.name,
cell.type,
cell.template,
])
const secondaryText = normalizeSearchText([
cell.referenceSummary,
cell.referenceSource,
cell.imageUrl,
cell.thumbnailUrl,
cell.generation?.provider,
cell.generation?.message,
cell.generation?.modelUrl,
cell.generation?.rawModelUrl,
])
const scored = ASSET_CATEGORIES
.map((rule) => ({
rule,
score:
scoreKeywords(primaryText, rule.keywords, 6) +
scoreKeywords(secondaryText, rule.keywords, 2) +
scoreKeywords(primaryText, rule.strongKeywords, 18) +
scoreKeywords(secondaryText, rule.strongKeywords, 5),
}))
.map((entry) => applyCategoryOverrides(entry, primaryText))
.sort((a, b) => b.score - a.score)
if (scored[0]?.score > 0) return buildCategoryWithInsight(scored[0].rule, cell.intelligence)
return ASSET_CATEGORIES.find((rule) => rule.id === 'product')
}
export function getSceneProfile(input = 'product') {
const profileId = typeof input === 'string'
? input
: input.sceneProfile || input.motionProfile || inferAssetCategory(input).sceneProfile
return SCENE_PROFILES[profileId] || SCENE_PROFILES.product
}
function applyCategoryOverrides(entry, primaryText) {
const score = entry.score
if (entry.rule.id === 'vessel' && hasKeyword(primaryText, ['aircraft carrier', 'aircraft car', '航母', '航空母舰'])) {
return { ...entry, score: score + 32 }
}
if (entry.rule.id === 'aircraft' && hasKeyword(primaryText, ['aircraft carrier', 'aircraft car', '航母', '航空母舰'])) {
return { ...entry, score: score - 18 }
}
if (entry.rule.id === 'road' && hasKeyword(primaryText, ['supercar', 'sports car', 'race car', 'ferrari', 'lamborghini', 'porsche', '跑车', '赛车', '法拉利', '兰博基尼', '保时捷'])) {
return { ...entry, score: score + 24 }
}
if (entry.rule.id === 'artifact' && hasKeyword(primaryText, ['sanxingdui', '三星堆', 'bronze mask', 'gold mask', '青铜人头像', '戴金面罩'])) {
return { ...entry, score: score + 28 }
}
return entry
}
function buildCategoryWithInsight(category, insight) {
if (!category || !insight?.configured) return category
return {
...category,
label: insight.categoryLabel || category.label,
material: insight.material || category.material,
description: insight.description || category.description,
value: insight.presentation || category.value,
inspectionFocus: insight.inspectionFocus || category.inspectionFocus,
tags: [...(Array.isArray(insight.tags) ? insight.tags : []), ...category.tags],
}
}
function normalizeCategoryOverride(value) {
const normalized = String(value || '').trim().toLowerCase()
return ASSET_CATEGORIES.some((rule) => rule.id === normalized) ? normalized : ''
}
function normalizeSearchText(parts) {
return parts
.filter(Boolean)
.join(' ')
.replace(/[_-]+/g, ' ')
.toLowerCase()
}
function hasKeyword(text, keywords) {
return keywords.some((keyword) => matchesKeyword(text, keyword))
}
function scoreKeywords(text, keywords = [], weight) {
return keywords.reduce((score, keyword) => (matchesKeyword(text, keyword) ? score + getKeywordWeight(keyword, weight) : score), 0)
}
function matchesKeyword(text, keyword) {
if (!text || !keyword) return false
const normalizedKeyword = keyword.toLowerCase()
if (/[a-z0-9]/i.test(normalizedKeyword)) {
const escaped = normalizedKeyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, 'i').test(text)
}
return text.includes(normalizedKeyword)
}
function getKeywordWeight(keyword, baseWeight) {
return keyword.length > 5 ? baseWeight + 2 : baseWeight
}
+84
View File
@@ -0,0 +1,84 @@
import { getProviderLabel } from '../services/modelApi.js'
import { getAssetIntelligence } from './assetIntelligence.js'
import { inferMotionProfile } from './motionProfiles.js'
export function getAssetMetadata(cell = {}) {
const { category, scene } = getAssetIntelligence(cell)
const provider = getAssetProviderLabel(cell)
const status = normalizeStatus(cell)
const motion = inferMotionProfile(cell)
const title = cell.fullName || cell.name || 'Untitled Asset'
const task = cell.generation?.taskId ? String(cell.generation.taskId).slice(0, 14) : 'none'
const source = getSourceLabel(cell)
return {
title,
subtitle: category.label,
accent: cell.accent || '#72a4bf',
insightSource: cell.intelligence?.configured ? `${cell.intelligence.provider || 'AI'} vision analysis` : 'asset name, source file, and generation metadata',
facts: [
['Category', category.label],
['Source', source],
['Provider', provider],
['Status', status],
['Scene', scene.label],
['Analyzer', cell.intelligence?.configured ? `${cell.intelligence.provider || 'AI'} vision` : 'Local rules'],
['Scale', category.scale],
['Task', task],
],
description: buildDescription(cell, category, scene),
value: buildValue(cell, category, scene, motion),
tags: dedupeTags([...category.tags, ...scene.badges, provider.toLowerCase(), status.toLowerCase().replace(/\s+/g, '-')]).slice(0, 8),
}
}
function buildDescription(cell, category, scene) {
if (cell.reference) {
return cell.referenceSummary || category.description
}
const modelState = cell.generation?.modelUrl
? 'A generated GLB is available, so the viewer is showing the actual cached 3D model.'
: cell.generation?.provider === 'cinematic'
? 'This is currently a browser-side depth preview rather than a full GLB mesh.'
: 'The viewer may use a procedural preview until the generated GLB is ready.'
return `${category.description} ${modelState} The selected presentation scene is ${scene.label}: ${scene.summary}`
}
function buildValue(cell, category, scene, motion) {
const material = `Material focus: ${category.material}.`
const structure = `Inspection focus: ${category.inspectionFocus}.`
const demo = `Recommended presentation: ${motion.label}. ${scene.summary} ${category.value}`
const warning = cell.generation?.status === 'failed'
? ' Current generation failed, so this asset should not be used for a final demo until retried.'
: ''
return `${material} ${structure} ${demo}${warning}`
}
function getSourceLabel(cell) {
if (cell.reference) return 'Khronos reference model'
if (cell.generation?.provider === 'local') return 'Local GLB import'
if (cell.imageUrl || cell.thumbnailUrl) return 'Uploaded reference image'
if (cell.custom) return 'Generated workspace asset'
return 'Built-in starter scene'
}
function getAssetProviderLabel(cell) {
if (cell.reference) return 'Khronos Reference'
if (!cell.custom && !cell.generation?.provider && !cell.generation?.requestedProvider) return 'Built-in'
return getProviderLabel(cell.generation?.provider || cell.generation?.requestedProvider)
}
function normalizeStatus(cell) {
if (cell.reference) return 'Reference ready'
if (cell.generation?.modelUrl) return 'GLB ready'
if (cell.generation?.status === 'failed') return 'Generation failed'
if (cell.generation?.status) return String(cell.generation.status)
return cell.custom ? 'Queued' : 'Interactive starter'
}
function dedupeTags(tags) {
return [...new Set(tags.filter(Boolean).map((tag) => String(tag).trim()).filter(Boolean))]
}
+95
View File
@@ -0,0 +1,95 @@
export function downloadJson(filename, payload) {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
downloadBlob(filename, blob)
}
export function downloadText(filename, text, type = 'text/plain;charset=utf-8') {
downloadBlob(filename, new Blob([text], { type }))
}
export function downloadBlob(filename, blob) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
export async function exportObjectAsGlb(object) {
if (!object) {
throw new Error('No exportable model is mounted.')
}
const { GLTFExporter } = await import('three/examples/jsm/exporters/GLTFExporter.js')
return new Promise((resolve, reject) => {
const exportRoot = object.clone(true)
exportRoot.traverse((node) => {
if (!node.isMesh && !node.isLine && !node.isLineSegments) return
node.castShadow = false
node.receiveShadow = false
if (Array.isArray(node.material)) {
node.material = node.material.map((material) => material.clone())
} else if (node.material) {
node.material = node.material.clone()
}
})
const exporter = new GLTFExporter()
exporter.parse(
exportRoot,
(result) => {
if (result instanceof ArrayBuffer) {
resolve(new Blob([result], { type: 'model/gltf-binary' }))
return
}
resolve(new Blob([JSON.stringify(result)], { type: 'model/gltf+json' }))
},
(error) => reject(error),
{
binary: true,
onlyVisible: true,
trs: false,
},
)
})
}
export function getCanvasImageDataUrl({ scale = 1, maxWidth = 0 } = {}) {
const canvas = document.querySelector('.cell-viewer canvas')
if (!canvas) return ''
try {
const outputScale = Math.max(1, Number(scale) || 1)
const widthScale = maxWidth > 0 ? Math.min(outputScale, maxWidth / canvas.width) : outputScale
const finalScale = Math.max(0.2, widthScale)
const output = document.createElement('canvas')
output.width = Math.max(1, Math.round(canvas.width * finalScale))
output.height = Math.max(1, Math.round(canvas.height * finalScale))
const context = output.getContext('2d')
context.imageSmoothingEnabled = true
context.imageSmoothingQuality = 'high'
context.drawImage(canvas, 0, 0, output.width, output.height)
return output.toDataURL('image/png')
} catch {
return ''
}
}
export function downloadCanvasImage(filename, scale = 1) {
const dataUrl = getCanvasImageDataUrl({ scale })
if (!dataUrl) return false
try {
const link = document.createElement('a')
link.href = dataUrl
link.download = filename
link.click()
return true
} catch {
return false
}
}
+475
View File
@@ -0,0 +1,475 @@
import * as THREE from 'three'
import { downloadBlob } from './downloads.js'
import { clamp, seeded } from './math.js'
import {
COMPACT_PERSISTED_IMAGE_EDGE,
MAX_PERSISTED_IMAGE_CHARS,
MAX_PERSISTED_IMAGE_EDGE,
} from '../config/appConfig.js'
function fileToDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file)
})
}
function loadImageFromUrl(url) {
return new Promise((resolve, reject) => {
const image = new window.Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error('Image could not be decoded.'))
image.src = url
})
}
function getCanvasDataUrl(canvas) {
const webp = canvas.toDataURL('image/webp', 0.9)
if (webp.startsWith('data:image/webp')) return webp
return canvas.toDataURL('image/png')
}
function getCanvasPngDataUrl(canvas) {
return canvas.toDataURL('image/png')
}
function resampleCanvas(sourceCanvas, maxEdge) {
const scale = Math.min(1, maxEdge / Math.max(sourceCanvas.width, sourceCanvas.height))
if (scale >= 1) return sourceCanvas
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(sourceCanvas.width * scale))
canvas.height = Math.max(1, Math.round(sourceCanvas.height * scale))
const context = canvas.getContext('2d')
context.imageSmoothingEnabled = true
context.imageSmoothingQuality = 'high'
context.drawImage(sourceCanvas, 0, 0, canvas.width, canvas.height)
return canvas
}
function trimTransparentCanvas(sourceCanvas, padding = 34) {
const context = sourceCanvas.getContext('2d')
const imageData = context.getImageData(0, 0, sourceCanvas.width, sourceCanvas.height)
const { data, width, height } = imageData
let minX = width
let minY = height
let maxX = -1
let maxY = -1
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const alpha = data[(y * width + x) * 4 + 3]
if (alpha < 10) continue
minX = Math.min(minX, x)
minY = Math.min(minY, y)
maxX = Math.max(maxX, x)
maxY = Math.max(maxY, y)
}
}
if (maxX < minX || maxY < minY) return sourceCanvas
const cropX = Math.max(0, minX - padding)
const cropY = Math.max(0, minY - padding)
const cropW = Math.min(width - cropX, maxX - minX + padding * 2)
const cropH = Math.min(height - cropY, maxY - minY + padding * 2)
const cropRatio = (cropW * cropH) / (width * height)
if (cropRatio > 0.94) return sourceCanvas
const canvas = document.createElement('canvas')
canvas.width = cropW
canvas.height = cropH
canvas.getContext('2d').drawImage(sourceCanvas, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH)
return canvas
}
function removeLightBackground(canvas) {
const context = canvas.getContext('2d')
const imageData = context.getImageData(0, 0, canvas.width, canvas.height)
const { data, width, height } = imageData
const sampleStep = Math.max(1, Math.floor(Math.min(width, height) / 90))
let edgeSamples = 0
let lightEdgeSamples = 0
function isLightNeutral(index) {
const r = data[index]
const g = data[index + 1]
const b = data[index + 2]
const brightness = (r + g + b) / 3
const chroma = Math.max(r, g, b) - Math.min(r, g, b)
return brightness > 232 && chroma < 42
}
for (let x = 0; x < width; x += sampleStep) {
edgeSamples += 2
if (isLightNeutral(x * 4)) lightEdgeSamples += 1
if (isLightNeutral(((height - 1) * width + x) * 4)) lightEdgeSamples += 1
}
for (let y = 0; y < height; y += sampleStep) {
edgeSamples += 2
if (isLightNeutral((y * width) * 4)) lightEdgeSamples += 1
if (isLightNeutral((y * width + width - 1) * 4)) lightEdgeSamples += 1
}
const shouldRemove = edgeSamples > 0 && lightEdgeSamples / edgeSamples > 0.42
if (!shouldRemove) return canvas
for (let index = 0; index < data.length; index += 4) {
const r = data[index]
const g = data[index + 1]
const b = data[index + 2]
const brightness = (r + g + b) / 3
const chroma = Math.max(r, g, b) - Math.min(r, g, b)
if (brightness > 242 && chroma < 36) {
data[index + 3] = 0
} else if (brightness > 224 && chroma < 46) {
const keep = Math.max(0, Math.min(1, (242 - brightness) / 18 + chroma / 92))
data[index + 3] = Math.round(data[index + 3] * keep)
}
}
context.putImageData(imageData, 0, 0)
return trimTransparentCanvas(canvas)
}
async function buildPersistentImageDataUrl(sourceUrl, maxEdge = MAX_PERSISTED_IMAGE_EDGE) {
const image = await loadImageFromUrl(sourceUrl)
const scale = Math.min(1, maxEdge / Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height))
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round((image.naturalWidth || image.width) * scale))
canvas.height = Math.max(1, Math.round((image.naturalHeight || image.height) * scale))
const context = canvas.getContext('2d')
context.imageSmoothingEnabled = true
context.imageSmoothingQuality = 'high'
context.drawImage(image, 0, 0, canvas.width, canvas.height)
const cutoutCanvas = removeLightBackground(canvas)
const dataUrl = getCanvasDataUrl(cutoutCanvas)
if (dataUrl.length <= MAX_PERSISTED_IMAGE_CHARS || maxEdge <= COMPACT_PERSISTED_IMAGE_EDGE) return dataUrl
return getCanvasDataUrl(resampleCanvas(cutoutCanvas, COMPACT_PERSISTED_IMAGE_EDGE))
}
export async function prepareImageForUpload(file) {
const sourceUrl = await fileToDataUrl(file)
if (typeof sourceUrl !== 'string' || !file.type.startsWith('image/')) {
return { displayUrl: sourceUrl, generationUrl: sourceUrl }
}
try {
return {
displayUrl: await buildPersistentImageDataUrl(sourceUrl),
generationUrl: sourceUrl,
}
} catch (error) {
console.warn(error)
return { displayUrl: sourceUrl, generationUrl: sourceUrl }
}
}
export async function createImageThumbnailDataUrl(sourceUrl, maxEdge = 160) {
if (!sourceUrl) return ''
try {
return await buildPersistentImageDataUrl(sourceUrl, maxEdge)
} catch (error) {
console.warn(error)
return ''
}
}
function createTransparentCanvas(width, height) {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
return canvas
}
async function createCutoutCanvasFromUrl(sourceUrl, maxEdge = COMPACT_PERSISTED_IMAGE_EDGE) {
const image = await loadImageFromUrl(sourceUrl)
const sourceWidth = image.naturalWidth || image.width
const sourceHeight = image.naturalHeight || image.height
const scale = Math.min(1, maxEdge / Math.max(sourceWidth, sourceHeight))
const canvas = createTransparentCanvas(Math.max(1, Math.round(sourceWidth * scale)), Math.max(1, Math.round(sourceHeight * scale)))
const context = canvas.getContext('2d', { willReadFrequently: true })
context.imageSmoothingEnabled = true
context.imageSmoothingQuality = 'high'
context.drawImage(image, 0, 0, canvas.width, canvas.height)
return removeLightBackground(canvas)
}
function createDerivedPngLayer(sourceCanvas, derivePixel) {
const inputContext = sourceCanvas.getContext('2d', { willReadFrequently: true })
const source = inputContext.getImageData(0, 0, sourceCanvas.width, sourceCanvas.height)
const outputCanvas = createTransparentCanvas(sourceCanvas.width, sourceCanvas.height)
const outputContext = outputCanvas.getContext('2d')
const output = outputContext.createImageData(sourceCanvas.width, sourceCanvas.height)
const { data } = source
const target = output.data
for (let y = 0; y < sourceCanvas.height; y += 1) {
for (let x = 0; x < sourceCanvas.width; x += 1) {
const index = (y * sourceCanvas.width + x) * 4
const alpha = data[index + 3]
if (alpha < 4) continue
const pixel = derivePixel(data[index], data[index + 1], data[index + 2], alpha, x, y, sourceCanvas.width, sourceCanvas.height)
if (!pixel) continue
target[index] = pixel[0]
target[index + 1] = pixel[1]
target[index + 2] = pixel[2]
target[index + 3] = pixel[3]
}
}
outputContext.putImageData(output, 0, 0)
return getCanvasPngDataUrl(outputCanvas)
}
function createRimPngLayer(sourceCanvas) {
const inputContext = sourceCanvas.getContext('2d', { willReadFrequently: true })
const source = inputContext.getImageData(0, 0, sourceCanvas.width, sourceCanvas.height)
const outputCanvas = createTransparentCanvas(sourceCanvas.width, sourceCanvas.height)
const outputContext = outputCanvas.getContext('2d')
const output = outputContext.createImageData(sourceCanvas.width, sourceCanvas.height)
const { data } = source
const target = output.data
const { width, height } = sourceCanvas
function alphaAt(x, y) {
if (x < 0 || x >= width || y < 0 || y >= height) return 0
return data[(y * width + x) * 4 + 3]
}
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const index = (y * width + x) * 4
const alpha = data[index + 3]
if (alpha < 18) continue
const edgeStrength = Math.max(0, alpha - Math.min(alphaAt(x - 2, y), alphaAt(x + 2, y), alphaAt(x, y - 2), alphaAt(x, y + 2)))
if (edgeStrength < 18) continue
target[index] = 120
target[index + 1] = 176
target[index + 2] = 210
target[index + 3] = Math.min(170, edgeStrength * 1.8)
}
}
outputContext.putImageData(output, 0, 0)
return getCanvasPngDataUrl(outputCanvas)
}
function createHighlightPngLayer(sourceCanvas) {
const canvas = createTransparentCanvas(sourceCanvas.width, sourceCanvas.height)
const context = canvas.getContext('2d')
const main = context.createRadialGradient(sourceCanvas.width * 0.34, sourceCanvas.height * 0.24, 0, sourceCanvas.width * 0.34, sourceCanvas.height * 0.24, sourceCanvas.width * 0.34)
main.addColorStop(0, 'rgba(255,255,255,0.62)')
main.addColorStop(0.34, 'rgba(255,255,255,0.16)')
main.addColorStop(1, 'rgba(255,255,255,0)')
context.fillStyle = main
context.fillRect(0, 0, canvas.width, canvas.height)
const secondary = context.createRadialGradient(sourceCanvas.width * 0.68, sourceCanvas.height * 0.66, 0, sourceCanvas.width * 0.68, sourceCanvas.height * 0.66, sourceCanvas.width * 0.28)
secondary.addColorStop(0, 'rgba(122,190,214,0.24)')
secondary.addColorStop(1, 'rgba(122,190,214,0)')
context.fillStyle = secondary
context.fillRect(0, 0, canvas.width, canvas.height)
context.globalCompositeOperation = 'destination-in'
context.drawImage(sourceCanvas, 0, 0)
return getCanvasPngDataUrl(canvas)
}
function createParticlePngLayer(sourceCanvas) {
const canvas = createTransparentCanvas(sourceCanvas.width, sourceCanvas.height)
const context = canvas.getContext('2d')
const colors = ['rgba(132,80,184,0.72)', 'rgba(223,112,70,0.62)', 'rgba(108,164,198,0.66)', 'rgba(125,176,92,0.56)']
for (let index = 0; index < 34; index += 1) {
const x = sourceCanvas.width * (0.16 + seeded(index + 800) * 0.68)
const y = sourceCanvas.height * (0.14 + seeded(index + 860) * 0.72)
const radius = 2.4 + seeded(index + 920) * 7.5
const gradient = context.createRadialGradient(x - radius * 0.28, y - radius * 0.32, 0, x, y, radius)
gradient.addColorStop(0, 'rgba(255,255,255,0.82)')
gradient.addColorStop(0.38, colors[index % colors.length])
gradient.addColorStop(1, 'rgba(255,255,255,0)')
context.fillStyle = gradient
context.beginPath()
context.arc(x, y, radius, 0, Math.PI * 2)
context.fill()
}
return getCanvasPngDataUrl(canvas)
}
export function createImageReliefGeometry(image) {
const sourceWidth = Math.max(1, image?.naturalWidth || image?.width || 1)
const sourceHeight = Math.max(1, image?.naturalHeight || image?.height || 1)
const aspect = sourceWidth / sourceHeight
const specimenWidth = aspect >= 1 ? 3.9 : 3.9 * aspect
const specimenHeight = aspect >= 1 ? 3.9 / aspect : 3.9
const sampleScale = Math.min(1, 190 / Math.max(sourceWidth, sourceHeight))
const sampleWidth = Math.max(24, Math.round(sourceWidth * sampleScale))
const sampleHeight = Math.max(24, Math.round(sourceHeight * sampleScale))
const canvas = createTransparentCanvas(sampleWidth, sampleHeight)
const context = canvas.getContext('2d', { willReadFrequently: true })
context.imageSmoothingEnabled = true
context.imageSmoothingQuality = 'high'
context.drawImage(image, 0, 0, sampleWidth, sampleHeight)
const { data } = context.getImageData(0, 0, sampleWidth, sampleHeight)
const segmentsX = Math.max(44, Math.min(96, Math.round(sampleWidth / 3.1)))
const segmentsY = Math.max(44, Math.min(96, Math.round(sampleHeight / 3.1)))
const geometry = new THREE.PlaneGeometry(specimenWidth, specimenHeight, segmentsX, segmentsY)
const slabGeometry = new THREE.PlaneGeometry(specimenWidth, specimenHeight, 1, 1)
const positions = geometry.attributes.position
const uvs = geometry.attributes.uv
function sampleAlpha(x, y) {
const px = Math.max(0, Math.min(sampleWidth - 1, x))
const py = Math.max(0, Math.min(sampleHeight - 1, y))
return data[(py * sampleWidth + px) * 4 + 3] / 255
}
for (let index = 0; index < positions.count; index += 1) {
const u = uvs.getX(index)
const v = uvs.getY(index)
const px = Math.max(0, Math.min(sampleWidth - 1, Math.round(u * (sampleWidth - 1))))
const py = Math.max(0, Math.min(sampleHeight - 1, Math.round((1 - v) * (sampleHeight - 1))))
const dataIndex = (py * sampleWidth + px) * 4
const r = data[dataIndex]
const g = data[dataIndex + 1]
const b = data[dataIndex + 2]
const rawAlpha = data[dataIndex + 3] / 255
const alpha = rawAlpha < 0.16 ? 0 : clamp((rawAlpha - 0.16) / 0.84)
const brightness = (r + g + b) / 765
const saturation = (Math.max(r, g, b) - Math.min(r, g, b)) / 255
const radial = clamp(1 - Math.hypot((u - 0.5) / 0.57, (v - 0.52) / 0.55))
const neighborAlpha = Math.min(
sampleAlpha(px - 2, py),
sampleAlpha(px + 2, py),
sampleAlpha(px, py - 2),
sampleAlpha(px, py + 2),
)
const contour = clamp((alpha - neighborAlpha) * 2.4)
const cellularNoise = Math.sin(u * 24 + v * 13) * 0.018 + Math.sin(u * 47 - v * 29) * 0.012
const depth = alpha <= 0
? -0.16
: alpha * (0.1 + radial * 0.58 + saturation * 0.22 + (1 - Math.abs(brightness - 0.58)) * 0.1 + cellularNoise) + contour * 0.2
positions.setZ(index, depth)
}
geometry.computeVertexNormals()
return {
aspect,
geometry,
slabGeometry,
}
}
export async function buildLayeredPngVisual(sourceUrl) {
const cutoutCanvas = await createCutoutCanvasFromUrl(sourceUrl)
const aspect = cutoutCanvas.width / cutoutCanvas.height
const bodyUrl = getCanvasPngDataUrl(cutoutCanvas)
const shadowUrl = createDerivedPngLayer(cutoutCanvas, (r, g, b, a) => [42, 55, 62, Math.round(a * 0.34)])
const depthUrl = createDerivedPngLayer(cutoutCanvas, (r, g, b, a) => [
Math.round(r * 0.72 + 84 * 0.28),
Math.round(g * 0.72 + 124 * 0.28),
Math.round(b * 0.72 + 148 * 0.28),
Math.round(a * 0.52),
])
const coreUrl = createDerivedPngLayer(cutoutCanvas, (r, g, b, a, x, y, width, height) => {
const nx = (x / width - 0.5) / 0.44
const ny = (y / height - 0.48) / 0.4
const mask = Math.max(0, 1 - Math.sqrt(nx * nx + ny * ny))
if (mask <= 0) return null
return [
Math.min(255, Math.round(r * 1.08 + 8)),
Math.min(255, Math.round(g * 1.04 + 6)),
Math.min(255, Math.round(b * 1.12 + 12)),
Math.round(a * Math.min(0.9, mask * 1.35)),
]
})
const frontUrl = createDerivedPngLayer(cutoutCanvas, (r, g, b, a, x, y, width, height) => {
const brightness = (r + g + b) / 3
const saturation = Math.max(r, g, b) - Math.min(r, g, b)
const detail = Math.max(0, Math.min(1, (saturation - 28) / 110 + (brightness - 116) / 260))
const upper = Math.max(0, 1 - Math.hypot((x / width - 0.56) / 0.42, (y / height - 0.38) / 0.46))
const mask = Math.max(detail * 0.85, upper * 0.52)
if (mask <= 0.08) return null
return [
Math.min(255, Math.round(r * 1.18 + 12)),
Math.min(255, Math.round(g * 1.12 + 8)),
Math.min(255, Math.round(b * 1.1 + 10)),
Math.round(a * Math.min(0.82, mask)),
]
})
return {
aspect,
layers: [
{ id: 'shadow', className: 'layer-shadow', url: shadowUrl, z: -130, shiftX: -28, shiftY: -18, scale: 1.1, opacity: 0.92, snapshotX: -18, snapshotY: 20 },
{ id: 'depth', className: 'layer-depth', url: depthUrl, z: -70, shiftX: -18, shiftY: -10, scale: 1.04, opacity: 0.78, snapshotX: -10, snapshotY: 8 },
{ id: 'rim', className: 'layer-rim', url: createRimPngLayer(cutoutCanvas), z: -20, shiftX: -8, shiftY: -4, scale: 1.025, opacity: 0.82, snapshotX: -3, snapshotY: 2 },
{ id: 'body', className: 'layer-body', url: bodyUrl, z: 18, shiftX: 8, shiftY: 5, scale: 1, opacity: 1, snapshotX: 0, snapshotY: 0 },
{ id: 'core', className: 'layer-core', url: coreUrl, z: 74, shiftX: 22, shiftY: 13, scale: 1.018, opacity: 0.94, snapshotX: 8, snapshotY: -3 },
{ id: 'front', className: 'layer-front', url: frontUrl, z: 128, shiftX: 34, shiftY: 22, scale: 1.036, opacity: 0.92, snapshotX: 16, snapshotY: -8 },
{ id: 'particles', className: 'layer-particles', url: createParticlePngLayer(cutoutCanvas), z: 170, shiftX: 46, shiftY: 28, scale: 1.08, opacity: 0.96, snapshotX: 24, snapshotY: -13 },
{ id: 'highlight', className: 'layer-highlight', url: createHighlightPngLayer(cutoutCanvas), z: 210, shiftX: 54, shiftY: 32, scale: 1.03, opacity: 0.78, snapshotX: 12, snapshotY: -10 },
],
}
}
function canvasToBlob(canvas, type = 'image/png', quality) {
return new Promise((resolve) => {
canvas.toBlob(resolve, type, quality)
})
}
async function drawImageToCanvas(context, url, x, y, width, height, opacity = 1, filter = 'none') {
const image = await loadImageFromUrl(url)
context.save()
context.globalAlpha = opacity
context.filter = filter
context.drawImage(image, x, y, width, height)
context.restore()
}
export async function downloadLayeredPngSnapshot(imageUrl, filename) {
const visual = await buildLayeredPngVisual(imageUrl)
const canvas = createTransparentCanvas(1400, 900)
const context = canvas.getContext('2d')
const backdrop = context.createLinearGradient(0, 0, canvas.width, canvas.height)
backdrop.addColorStop(0, '#fbf5e8')
backdrop.addColorStop(1, '#edf6f0')
context.fillStyle = backdrop
context.fillRect(0, 0, canvas.width, canvas.height)
const specimenWidth = visual.aspect >= 1 ? 760 : 760 * visual.aspect
const specimenHeight = visual.aspect >= 1 ? 760 / visual.aspect : 760
const originX = (canvas.width - specimenWidth) / 2
const originY = (canvas.height - specimenHeight) / 2 + 10
for (const layer of visual.layers) {
await drawImageToCanvas(
context,
layer.url,
originX + layer.snapshotX,
originY + layer.snapshotY,
specimenWidth,
specimenHeight,
layer.opacity,
layer.id === 'shadow' ? 'blur(16px)' : 'none',
)
}
const blob = await canvasToBlob(canvas)
if (!blob) return false
downloadBlob(filename, blob)
return true
}
+20
View File
@@ -0,0 +1,20 @@
export function seeded(index) {
const value = Math.sin(index * 12.9898 + 78.233) * 43758.5453
return value - Math.floor(value)
}
export function clamp(value, min = 0, max = 1) {
return Math.min(max, Math.max(min, value))
}
export function pickSpherePoint(index, radius = 1) {
const theta = seeded(index * 3) * Math.PI * 2
const phi = Math.acos(2 * seeded(index * 3 + 1) - 1)
const spread = radius * (0.86 + seeded(index * 3 + 2) * 0.16)
return [
Math.sin(phi) * Math.cos(theta) * spread,
Math.sin(phi) * Math.sin(theta) * spread,
Math.cos(phi) * spread,
]
}
+152
View File
@@ -0,0 +1,152 @@
import { apiUrl, getProviderLabel } from '../services/modelApi.js'
const MODEL_METRIC_CACHE = new Map()
export async function inspectModelUrl(modelUrl) {
if (!modelUrl) return null
if (MODEL_METRIC_CACHE.has(modelUrl)) return MODEL_METRIC_CACHE.get(modelUrl)
const promise = inspectModelUrlUncached(modelUrl)
MODEL_METRIC_CACHE.set(modelUrl, promise)
return promise
}
async function inspectModelUrlUncached(modelUrl) {
const resolvedUrl = apiUrl(modelUrl)
const response = await fetch(resolvedUrl)
if (!response.ok) {
throw new Error(`Model metrics unavailable (${response.status})`)
}
const headerSize = Number(response.headers.get('content-length'))
const buffer = await response.arrayBuffer()
const { GLTFLoader } = await import('three/examples/jsm/loaders/GLTFLoader.js')
const loader = new GLTFLoader()
const gltf = await new Promise((resolve, reject) => {
loader.parse(buffer, '', resolve, reject)
})
return extractSceneMetrics(gltf.scene, Number.isFinite(headerSize) && headerSize > 0 ? headerSize : buffer.byteLength)
}
function extractSceneMetrics(scene, fileBytes = 0) {
let nodeCount = 0
let meshCount = 0
let triangleCount = 0
const materials = new Set()
const textures = new Set()
const textureSlots = ['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'aoMap', 'emissiveMap', 'alphaMap', 'bumpMap', 'displacementMap']
scene.traverse((node) => {
nodeCount += 1
if (!node.isMesh) return
meshCount += 1
const geometry = node.geometry
const positionCount = geometry?.attributes?.position?.count || 0
triangleCount += geometry?.index?.count ? Math.floor(geometry.index.count / 3) : Math.floor(positionCount / 3)
const nodeMaterials = Array.isArray(node.material) ? node.material : [node.material].filter(Boolean)
nodeMaterials.forEach((material) => {
materials.add(material)
textureSlots.forEach((slot) => {
if (material?.[slot]) textures.add(material[slot])
})
})
})
return {
fileBytes,
nodeCount,
meshCount,
materialCount: materials.size,
textureCount: textures.size,
triangleCount,
inspectedAt: new Date().toISOString(),
}
}
export function getModelQuality(cell, metrics, generationHistory = []) {
const generation = cell.custom ? cell.generation || {} : {}
const status = String(generation.status || (cell.custom ? 'pending' : 'built-in')).toLowerCase()
const hasGlb = Boolean(generation.modelUrl)
const provider = generation.provider || (cell.custom ? 'unknown' : 'built-in')
const history = generationHistory.find((entry) => entry.cellId === cell.id && ['success', 'failed'].includes(String(entry.status).toLowerCase()))
const durationMs = history?.durationMs
const failed = status === 'failed'
const loadingMetrics = hasGlb && !metrics
const metricError = metrics?.error || ''
const score = calculateScore({ cell, generation, metrics, durationMs, failed, hasGlb })
return {
score,
verdict: getVerdict(score, { cell, failed, hasGlb, metricError }),
providerLabel: provider === 'built-in' ? 'Built-in' : getProviderLabel(provider),
status,
hasGlb,
durationMs,
loadingMetrics,
metricError,
fileBytes: metrics?.fileBytes || 0,
nodeCount: metrics?.nodeCount || 0,
meshCount: metrics?.meshCount || 0,
materialCount: metrics?.materialCount || 0,
textureCount: metrics?.textureCount || 0,
triangleCount: metrics?.triangleCount || 0,
}
}
function calculateScore({ cell, generation, metrics, durationMs, failed, hasGlb }) {
if (failed) return 12
if (generation?.status && !['success', 'local'].includes(String(generation.status).toLowerCase()) && !hasGlb) return 38
let score = cell.custom ? 28 : 68
if (hasGlb) score += 28
else if (generation?.provider === 'cinematic') score += 12
if (metrics?.triangleCount >= 50000) score += 16
else if (metrics?.triangleCount >= 10000) score += 13
else if (metrics?.triangleCount >= 2000) score += 9
else if (metrics?.triangleCount > 0) score += 5
if (metrics?.textureCount >= 4) score += 12
else if (metrics?.textureCount > 0) score += 8
else if (hasGlb) score += 2
if (metrics?.meshCount >= 8) score += 7
else if (metrics?.meshCount >= 2) score += 4
if (metrics?.fileBytes >= 2_000_000) score += 5
if (Number.isFinite(durationMs) && durationMs > 0 && durationMs < 180_000) score += 4
return Math.max(0, Math.min(98, Math.round(score)))
}
function getVerdict(score, { cell, failed, hasGlb, metricError }) {
if (failed) return 'Failed'
if (metricError) return 'GLB loaded, metrics limited'
if (cell.custom && !hasGlb && cell.generation?.provider === 'cinematic') return 'Preview only'
if (cell.custom && !hasGlb) return 'Waiting for GLB'
if (score >= 86) return 'Demo-ready'
if (score >= 72) return 'Solid'
if (score >= 55) return 'Usable'
return 'Needs better source'
}
export function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return 'n/a'
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(bytes >= 10_000_000 ? 0 : 1)} MB`
if (bytes >= 1000) return `${Math.round(bytes / 1000)} KB`
return `${Math.round(bytes)} B`
}
export function formatDuration(ms) {
if (!Number.isFinite(ms) || ms <= 0) return 'n/a'
if (ms >= 60_000) return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`
return `${Math.max(1, Math.round(ms / 1000))}s`
}
export function formatNumber(value) {
if (!Number.isFinite(value) || value <= 0) return '0'
return new Intl.NumberFormat(undefined, { notation: value >= 100000 ? 'compact' : 'standard' }).format(value)
}
+81
View File
@@ -0,0 +1,81 @@
const DB_NAME = 'model-studio-3d-assets'
const DB_VERSION = 1
const STORE_NAME = 'models'
export async function listStoredModels() {
if (!canUseIndexedDb()) return []
try {
const db = await openModelDb()
const models = await requestToPromise(db.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).getAll())
return sortModels(models)
} catch {
return []
}
}
export async function saveStoredModels(models) {
if (!canUseIndexedDb()) return false
try {
const db = await openModelDb()
const transaction = db.transaction(STORE_NAME, 'readwrite')
const done = transactionToPromise(transaction)
const store = transaction.objectStore(STORE_NAME)
store.clear()
;(Array.isArray(models) ? models : []).forEach((model, index) => {
store.put({
...model,
libraryOrder: index,
savedAt: model.savedAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
})
await done
return true
} catch {
return false
}
}
function canUseIndexedDb() {
return typeof window !== 'undefined' && Boolean(window.indexedDB)
}
function openModelDb() {
return new Promise((resolve, reject) => {
const request = window.indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
function requestToPromise(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
function transactionToPromise(transaction) {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(transaction.error)
transaction.onabort = () => reject(transaction.error)
})
}
function sortModels(models) {
return [...(Array.isArray(models) ? models : [])].sort((a, b) => {
if (Number.isFinite(a.libraryOrder) && Number.isFinite(b.libraryOrder)) {
return a.libraryOrder - b.libraryOrder
}
return String(b.savedAt || '').localeCompare(String(a.savedAt || ''))
})
}
+51
View File
@@ -0,0 +1,51 @@
import { inferAssetCategory } from './assetIntelligence.js'
const MOTION_PROFILES = {
artifact: {
id: 'artifact',
label: 'Museum turntable',
durationMs: 9000,
description: 'Dark-gallery turntable with close material inspection for artifacts.',
},
road: {
id: 'road',
label: 'Road push-in',
durationMs: 7800,
description: 'Low front dolly, forward drift, and showroom reveal for cars.',
},
aircraft: {
id: 'aircraft',
label: 'Flight pass',
durationMs: 7200,
description: 'Banked fly-by with a light tracking camera for aircraft.',
},
vessel: {
id: 'vessel',
label: 'Naval cruise',
durationMs: 8600,
description: 'Slow side tracking and heavy mass movement for ships and carriers.',
},
specimen: {
id: 'specimen',
label: 'Specimen orbit',
durationMs: 8200,
description: 'Close inspection orbit for biological and organic subjects.',
},
product: {
id: 'product',
label: 'Studio reveal',
durationMs: 7600,
description: 'Product turntable with a push-in and detail pause.',
},
}
export function inferMotionProfile(cell = {}) {
if (MOTION_PROFILES[cell.motionProfile]) return MOTION_PROFILES[cell.motionProfile]
const category = inferAssetCategory(cell)
return MOTION_PROFILES[category.motionProfile] || MOTION_PROFILES.product
}
export function getMotionProfile(profileId) {
return MOTION_PROFILES[profileId] || MOTION_PROFILES.product
}
+101
View File
@@ -0,0 +1,101 @@
import { PROJECT_FALLBACK_STORAGE_KEY } from '../config/appConfig.js'
import { loadStoredValue, storeValue } from './storage.js'
const DB_NAME = '3dcellforge-projects'
const DB_VERSION = 1
const STORE_NAME = 'projects'
export async function listProjects() {
if (!canUseIndexedDb()) return getFallbackProjects()
const db = await openProjectDb()
return requestToPromise(db.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).getAll())
.then((projects) => sortProjects(projects))
.catch(() => getFallbackProjects())
}
export async function saveProject(project) {
const next = {
...project,
id: project.id || `project-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
savedAt: new Date().toISOString(),
version: 1,
}
if (!canUseIndexedDb()) {
saveFallbackProject(next)
return next
}
try {
const db = await openProjectDb()
await requestToPromise(db.transaction(STORE_NAME, 'readwrite').objectStore(STORE_NAME).put(next))
return next
} catch {
saveFallbackProject(next)
return next
}
}
export async function loadProject(projectId) {
if (!canUseIndexedDb()) return getFallbackProjects().find((project) => project.id === projectId) || null
try {
const db = await openProjectDb()
return await requestToPromise(db.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).get(projectId))
} catch {
return getFallbackProjects().find((project) => project.id === projectId) || null
}
}
export async function deleteProject(projectId) {
if (!canUseIndexedDb()) {
storeValue(PROJECT_FALLBACK_STORAGE_KEY, getFallbackProjects().filter((project) => project.id !== projectId))
return
}
try {
const db = await openProjectDb()
await requestToPromise(db.transaction(STORE_NAME, 'readwrite').objectStore(STORE_NAME).delete(projectId))
} catch {
storeValue(PROJECT_FALLBACK_STORAGE_KEY, getFallbackProjects().filter((project) => project.id !== projectId))
}
}
function canUseIndexedDb() {
return typeof window !== 'undefined' && Boolean(window.indexedDB)
}
function openProjectDb() {
return new Promise((resolve, reject) => {
const request = window.indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
function requestToPromise(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
function getFallbackProjects() {
return sortProjects(loadStoredValue(PROJECT_FALLBACK_STORAGE_KEY, []))
}
function saveFallbackProject(project) {
const projects = getFallbackProjects().filter((item) => item.id !== project.id)
storeValue(PROJECT_FALLBACK_STORAGE_KEY, sortProjects([project, ...projects]).slice(0, 20))
}
function sortProjects(projects) {
return [...(Array.isArray(projects) ? projects : [])].sort((a, b) => String(b.savedAt || '').localeCompare(String(a.savedAt || '')))
}
+18
View File
@@ -0,0 +1,18 @@
export function loadStoredValue(key, fallback) {
try {
const raw = window.localStorage.getItem(key)
return raw ? JSON.parse(raw) : fallback
} catch {
return fallback
}
}
export function storeValue(key, value) {
try {
window.localStorage.setItem(key, JSON.stringify(value))
return true
} catch {
// Storage can fail in private browsing; the UI should keep working.
return false
}
}
+8
View File
@@ -0,0 +1,8 @@
export function canUseWebGL() {
try {
const canvas = document.createElement('canvas')
return Boolean(canvas.getContext('webgl2') || canvas.getContext('webgl'))
} catch {
return false
}
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
+104
View File
@@ -0,0 +1,104 @@
import {
GENERATION_POLL_INTERVAL_MS,
GENERATION_PROVIDER_OPTIONS,
GENERATION_TIMEOUT_MS,
MODEL_API_BASE,
} from '../config/appConfig.js'
export function apiUrl(path) {
if (/^https?:\/\//i.test(path)) return path
const normalized = path.startsWith('/') ? path : `/${path}`
if (!normalized.startsWith('/api/')) return normalized
return `${MODEL_API_BASE.replace(/\/$/, '')}${normalized}`
}
export function delay(ms) {
return new Promise((resolve) => {
window.setTimeout(resolve, ms)
})
}
export async function readApiResponse(response) {
const payload = await response.json().catch(() => ({}))
if (!response.ok || payload.error) {
throw new Error(payload.error || `Request failed with ${response.status}`)
}
return payload
}
export function getProviderPlan(provider) {
return provider === 'auto' ? ['rodin', 'tripo', 'fal', 'hunyuan', 'cinematic'] : [provider || 'rodin']
}
export function getProviderLabel(provider) {
if (provider === 'local') return 'Local'
if (provider === 'cinematic') return 'JS Depth'
if (provider === 'reference') return 'Khronos Reference'
return GENERATION_PROVIDER_OPTIONS.find((item) => item.id === provider)?.label ?? 'Hyper3D'
}
export async function create3dGeneration({ provider, imageDataUrl, fileName, prompt, modelId }) {
const response = await fetch(apiUrl('/api/3d/generate'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider, imageDataUrl, fileName, prompt, modelId }),
})
return readApiResponse(response)
}
export async function analyzeAssetImage({ imageDataUrl, fileName }) {
const response = await fetch(apiUrl('/api/3d/analyze'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageDataUrl, fileName }),
})
return readApiResponse(response)
}
export async function uploadLocal3dModel(file) {
const response = await fetch(apiUrl(`/api/3d/local-model?fileName=${encodeURIComponent(file.name)}`), {
method: 'POST',
headers: { 'Content-Type': file.type || 'model/gltf-binary' },
body: file,
})
return readApiResponse(response)
}
export async function get3dApiHealth() {
const response = await fetch(apiUrl('/api/3d/health'))
return readApiResponse(response)
}
export async function get3dServerLogs(limit = 100) {
const response = await fetch(apiUrl(`/api/3d/logs?limit=${encodeURIComponent(limit)}`))
return readApiResponse(response)
}
export async function get3dGenerationStatus(taskId, provider) {
const response = await fetch(apiUrl(`/api/3d/status/${encodeURIComponent(taskId)}?provider=${encodeURIComponent(provider || 'rodin')}`))
return readApiResponse(response)
}
export async function waitFor3dModel(taskId, provider, onStatus) {
const deadline = Date.now() + GENERATION_TIMEOUT_MS
while (Date.now() < deadline) {
await delay(GENERATION_POLL_INTERVAL_MS)
const status = await get3dGenerationStatus(taskId, provider)
onStatus?.(status)
if (['success', 'completed', 'complete', 'done'].includes(String(status.status).toLowerCase())) {
if (!status.modelUrl) throw new Error(`${getProviderLabel(provider)} finished but no GLB model URL was returned.`)
return status
}
if (['failed', 'error', 'cancelled', 'canceled'].includes(String(status.status).toLowerCase())) {
throw new Error(status.error || `${getProviderLabel(provider)} generation failed.`)
}
}
throw new Error(`${getProviderLabel(provider)} generation timed out.`)
}
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { getAssetIntelligence, getSceneProfile, inferAssetCategory } from '../src/lib/assetIntelligence.js'
describe('asset intelligence', () => {
it('uses a road scene for generated supercars', () => {
const intelligence = getAssetIntelligence({
name: 'hyper3d supercar test',
sourceFileName: 'red-ferrari-supercar.png',
})
assert.equal(intelligence.category.id, 'road')
assert.equal(intelligence.scene.id, 'road')
assert.match(intelligence.scene.summary, /road deck/i)
})
it('keeps aircraft carriers in the vessel scene even when aircraft appears first', () => {
const category = inferAssetCategory({
name: 'Chinese aircraft carrier',
sourceFileName: 'chinese-aircraft-carrier.png',
})
assert.equal(category.id, 'vessel')
assert.equal(getSceneProfile(category.sceneProfile).id, 'vessel')
})
it('maps bronze mask artifacts to museum presentation', () => {
const intelligence = getAssetIntelligence({
name: '戴金面罩青铜人头像',
sourceFileName: 'sanxingdui-bronze-mask.png',
})
assert.equal(intelligence.category.id, 'artifact')
assert.equal(intelligence.scene.label, 'Museum Turntable')
})
it('trusts configured vision analysis over ambiguous filenames', () => {
const intelligence = getAssetIntelligence({
name: 'demo upload',
sourceFileName: 'unknown-image.png',
intelligence: {
configured: true,
categoryId: 'artifact',
categoryLabel: 'Museum Artifact',
description: 'Ancient bronze ritual object.',
material: 'Aged bronze and gold foil.',
inspectionFocus: 'face relief and patina',
presentation: 'Use a dark museum turntable.',
tags: ['bronze', 'mask'],
},
})
assert.equal(intelligence.category.id, 'artifact')
assert.equal(intelligence.category.material, 'Aged bronze and gold foil.')
assert.equal(intelligence.scene.id, 'artifact')
})
})
+63
View File
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { getAssetMetadata } from '../src/lib/assetMetadata.js'
describe('asset metadata inference', () => {
it('describes museum artifacts from Chinese artifact keywords', () => {
const metadata = getAssetMetadata({
id: 'sanxingdui-mask',
name: '戴金面罩青铜人头像',
sourceFileName: '三星堆-戴金面罩青铜人头像.png',
type: 'Uploaded 3D Asset',
custom: true,
generation: { provider: 'rodin', status: 'success', modelUrl: '/api/3d/local-model/mask.glb' },
})
assert.equal(metadata.subtitle, 'Museum Artifact')
assert.match(metadata.description, /museum-style artifact/i)
assert.deepEqual(metadata.facts.find(([label]) => label === 'Scene'), ['Scene', 'Museum Turntable'])
assert.ok(metadata.tags.includes('artifact'))
})
it('keeps generated supercars on the road presentation path', () => {
const metadata = getAssetMetadata({
id: 'custom-supercar',
name: 'hyper3d supercar test',
fullName: 'hyper3d supercar test',
sourceFileName: 'red-supercar.png',
custom: true,
template: 'animal',
generation: { provider: 'rodin', status: 'success', modelUrl: '/api/3d/local-model/car.glb' },
})
assert.equal(metadata.subtitle, 'Performance Vehicle')
assert.match(metadata.value, /Road push-in/)
assert.ok(metadata.tags.includes('low camera'))
})
it('classifies aircraft carriers as vessels instead of aircraft', () => {
const metadata = getAssetMetadata({
id: 'carrier',
name: 'chinese aircraft carrier',
sourceFileName: 'chinese-aircraft-carrier.png',
custom: true,
generation: { provider: 'fal', status: 'success', modelUrl: '/api/3d/local-model/carrier.glb' },
})
assert.equal(metadata.subtitle, 'Naval Vessel')
assert.match(metadata.value, /Naval cruise/)
})
it('does not label built-in starter scenes as Hyper3D assets', () => {
const metadata = getAssetMetadata({
id: 'plant',
name: 'Plant Specimen',
type: 'Starter Asset',
template: 'plant',
custom: false,
})
assert.deepEqual(metadata.facts.find(([label]) => label === 'Provider'), ['Provider', 'Built-in'])
})
})
+63
View File
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { formatBytes, formatDuration, formatNumber, getModelQuality } from '../src/lib/modelQuality.js'
test('model quality scoring', async (t) => {
await t.test('keeps built-in starter models in the usable range', () => {
const quality = getModelQuality({ id: 'plant', custom: false }, null, [])
assert.equal(quality.score, 68)
assert.equal(quality.verdict, 'Usable')
assert.equal(quality.providerLabel, 'Built-in')
assert.equal(quality.hasGlb, false)
})
await t.test('rewards generated GLB assets with geometry and textures', () => {
const quality = getModelQuality(
{
id: 'custom-1',
custom: true,
generation: {
provider: 'hyper3d',
status: 'success',
modelUrl: '/api/3d/local-model/custom-1.glb',
},
},
{
fileBytes: 2_400_000,
meshCount: 12,
textureCount: 5,
triangleCount: 72_000,
},
[{ cellId: 'custom-1', status: 'success', durationMs: 92_000 }],
)
assert.equal(quality.score, 98)
assert.equal(quality.verdict, 'Demo-ready')
assert.equal(quality.hasGlb, true)
assert.equal(quality.fileBytes, 2_400_000)
})
await t.test('keeps failed generations clearly below demo quality', () => {
const quality = getModelQuality({
id: 'custom-failed',
custom: true,
generation: {
provider: 'tripo',
status: 'failed',
modelUrl: '',
},
})
assert.equal(quality.score, 12)
assert.equal(quality.verdict, 'Failed')
})
})
test('model quality formatters', () => {
assert.equal(formatBytes(0), 'n/a')
assert.equal(formatBytes(2_400_000), '2.4 MB')
assert.equal(formatDuration(92_000), '1m 32s')
assert.equal(formatNumber(72_000), '72,000')
})
+15
View File
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { inferMotionProfile } from '../src/lib/motionProfiles.js'
test('infers object-aware demo motion profiles', () => {
assert.equal(inferMotionProfile({ name: 'hyper3d supercar test' }).id, 'road')
assert.equal(inferMotionProfile({ name: 'hyper3d supercar test', generation: { modelUrl: '/generated-models/aircraft-test.glb' } }).id, 'road')
assert.equal(inferMotionProfile({ name: 'advanced fighter jet render' }).id, 'aircraft')
assert.equal(inferMotionProfile({ name: 'Chinese aircraft carrier' }).id, 'vessel')
assert.equal(inferMotionProfile({ name: 'chinese aircraft car...' }).id, 'vessel')
assert.equal(inferMotionProfile({ name: '戴金面罩青铜人头像', sourceFileName: 'sanxingdui-bronze-mask.png' }).id, 'artifact')
assert.equal(inferMotionProfile({ name: 'Plant Cell', template: 'plant' }).id, 'specimen')
assert.equal(inferMotionProfile({ name: 'Luxury watch model' }).id, 'product')
})
+247
View File
@@ -0,0 +1,247 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { assertLocalDiagnosticsRequest, parseDataUrl, sanitizeFileName } from '../server/http-utils.mjs'
import { getModelExtension, shouldAttachTripoAuth, validateModelBuffer } from '../server/model-store.mjs'
import { findFirstValue, findModelUrl, isSuccessStatus } from '../server/object-utils.mjs'
import { buildFalInput, decodeFalTaskId, encodeFalTaskId, findFalModelFile, normalizeFalModelId, normalizeFalStatus } from '../server/providers/fal.mjs'
import { decodeRodinTaskId, encodeRodinTaskId, findRodinDownloadItem, normalizeRodinStatus } from '../server/providers/rodin.mjs'
import { extractJsonObject, normalizeVisionInsight } from '../server/providers/vision.mjs'
import { compactCustomCellsForStorage, persistCustomCells } from '../src/domain/cellPersistence.js'
describe('server utility functions', () => {
it('sanitizes uploaded filenames without losing readable words', () => {
assert.equal(sanitizeFileName('../plant cell ✨.png'), 'plant cell .png')
assert.equal(sanitizeFileName(''), 'asset-reference.png')
})
it('parses supported image data URLs and rejects tiny payloads', () => {
const dataUrl = `data:image/png;base64,${Buffer.alloc(1024).toString('base64')}`
const image = parseDataUrl(dataUrl)
assert.equal(image.mime, 'image/png')
assert.equal(image.ext, 'png')
assert.equal(image.buffer.length, 1024)
assert.throws(() => parseDataUrl('data:text/plain;base64,abc'), /Only PNG, JPEG, or WebP/)
assert.throws(() => parseDataUrl(`data:image/png;base64,${Buffer.alloc(8).toString('base64')}`), /too small/)
})
it('restricts diagnostics logs to local callers and localhost pages', () => {
assert.doesNotThrow(() => assertLocalDiagnosticsRequest({
socket: { remoteAddress: '127.0.0.1' },
headers: { origin: 'http://127.0.0.1:5174' },
}))
assert.doesNotThrow(() => assertLocalDiagnosticsRequest({
socket: { remoteAddress: '::ffff:127.0.0.1' },
headers: { referer: 'http://localhost:5174/logs' },
}))
assert.throws(
() => assertLocalDiagnosticsRequest({
socket: { remoteAddress: '192.168.1.8' },
headers: {},
}),
/only available from this machine/,
)
assert.throws(
() => assertLocalDiagnosticsRequest({
socket: { remoteAddress: '127.0.0.1' },
headers: { origin: 'https://example.com' },
}),
/only available to localhost pages/,
)
})
it('detects model extensions and validates GLB headers', () => {
assert.equal(getModelExtension('https://example.com/model.glb?download=1'), 'glb')
assert.equal(getModelExtension('scene.gltf'), 'gltf')
assert.throws(() => getModelExtension('model.obj'), /Only GLB/)
assert.doesNotThrow(() => validateModelBuffer(Buffer.concat([Buffer.from('glTF'), Buffer.alloc(28)]), 'glb'))
assert.throws(() => validateModelBuffer(Buffer.concat([Buffer.from('nope'), Buffer.alloc(28)]), 'glb'), /GLB files/)
})
it('does not attach Tripo auth to arbitrary model URLs', () => {
assert.equal(shouldAttachTripoAuth('https://example.com/model.glb'), false)
assert.equal(shouldAttachTripoAuth('http://127.0.0.1:8787/model.glb'), false)
})
it('finds nested task ids and preferred model URLs', () => {
const payload = {
data: {
task: { task_id: 'task-123' },
assets: [
{ url: 'https://example.com/preview.png' },
{ result: 'https://example.com/model.obj' },
{ result: 'https://example.com/model.glb?x=1' },
],
},
}
assert.equal(findFirstValue(payload, ['task_id']), 'task-123')
assert.equal(findModelUrl(payload), 'https://example.com/model.glb?x=1')
assert.equal(findModelUrl({ result: 'https://example.com/model.obj' }), '')
assert.equal(isSuccessStatus('finished'), true)
assert.equal(isSuccessStatus('running'), false)
})
it('normalizes model vision output for asset intelligence', () => {
const parsed = extractJsonObject('```json\n{"objectName":"Red Supercar","categoryId":"car","confidence":92,"tags":["Vehicle","Gloss"]}\n```')
const insight = normalizeVisionInsight(parsed, { provider: 'openai', model: 'test-model' })
assert.equal(insight.objectName, 'Red Supercar')
assert.equal(insight.categoryId, 'road')
assert.equal(insight.categoryLabel, 'Performance Vehicle')
assert.equal(insight.confidence, 0.92)
assert.deepEqual(insight.tags, ['vehicle', 'gloss'])
})
it('normalizes Rodin task ids, statuses, and downloads', () => {
const task = { taskUuid: 'task-uuid-1', subscriptionKey: 'subscription-key-1' }
const encoded = encodeRodinTaskId(task)
assert.deepEqual(decodeRodinTaskId(encoded), task)
assert.deepEqual(decodeRodinTaskId('legacy-task-id'), { taskUuid: 'legacy-task-id', subscriptionKey: 'legacy-task-id' })
assert.equal(normalizeRodinStatus(['Done', 'Done']), 'success')
assert.equal(normalizeRodinStatus(['Waiting']), 'queued')
assert.equal(normalizeRodinStatus(['Generating']), 'running')
assert.equal(normalizeRodinStatus(['Done', 'Failed']), 'failed')
assert.deepEqual(
findRodinDownloadItem({
list: [
{ name: 'preview.webp', url: 'https://example.com/preview.webp' },
{ name: 'model.glb', url: 'https://cdn.example.com/signed-download' },
],
}),
{ name: 'model.glb', url: 'https://cdn.example.com/signed-download' },
)
})
it('normalizes Fal model ids, task ids, inputs, statuses, and model files', () => {
const task = { modelId: 'tripo3d/tripo/v2.5/image-to-3d', requestId: 'fal-request-1' }
const encoded = encodeFalTaskId(task)
assert.deepEqual(decodeFalTaskId(encoded), task)
assert.equal(normalizeFalModelId('tripo3d/tripo/v2.5/image-to-3d'), 'tripo3d/tripo/v2.5/image-to-3d')
assert.equal(normalizeFalModelId('fal-ai/tripo3d/v2.5/image-to-3d'), 'fal-ai/hunyuan3d/v2')
assert.equal(normalizeFalStatus('IN_QUEUE'), 'queued')
assert.equal(normalizeFalStatus('IN_PROGRESS'), 'running')
assert.equal(normalizeFalStatus('COMPLETED'), 'success')
assert.equal(normalizeFalStatus('ERROR'), 'failed')
assert.deepEqual(
buildFalInput('fal-ai/hunyuan3d/v2', 'https://cdn.example.com/input.png', { seed: 12 }),
{ input_image_url: 'https://cdn.example.com/input.png', seed: 12 },
)
assert.deepEqual(
buildFalInput('fal-ai/hyper3d/rodin', 'https://cdn.example.com/input.png', { prompt: 'a detailed cell', seed: 7 }),
{
geometry_file_format: 'glb',
input_image_urls: ['https://cdn.example.com/input.png'],
material: 'PBR',
prompt: 'a detailed cell',
quality: 'medium',
seed: 7,
tier: 'Regular',
},
)
assert.deepEqual(
findFalModelFile({
base_model: { url: 'https://cdn.example.com/base.glb' },
pbr_model: { url: 'https://cdn.example.com/file', content_type: 'model/gltf-binary' },
}),
{ url: 'https://cdn.example.com/file', ext: 'glb' },
)
})
it('compacts generated custom cells without dropping pending retry images first', () => {
const generated = {
id: 'custom-ready',
imageUrl: 'data:image/webp;base64,large',
thumbnailUrl: 'data:image/webp;base64,thumb',
generation: { status: 'success', modelUrl: '/api/3d/local-model/task.glb', rawModelUrl: 'https://signed.example.com/model.glb', message: 'ready' },
}
const pending = {
id: 'custom-pending',
imageUrl: 'data:image/webp;base64,source',
generation: { status: 'failed', modelUrl: '', rawModelUrl: '', message: 'retry possible' },
}
assert.deepEqual(
compactCustomCellsForStorage([generated, pending], 'generated-previews').map((cell) => cell.imageUrl),
['', pending.imageUrl],
)
assert.equal(compactCustomCellsForStorage([generated, pending], 'generated-previews')[0].thumbnailUrl, generated.thumbnailUrl)
assert.deepEqual(
compactCustomCellsForStorage([generated, pending], 'minimal').map((cell) => ({
imageUrl: cell.imageUrl,
thumbnailUrl: cell.thumbnailUrl,
rawModelUrl: cell.generation.rawModelUrl,
})),
[
{ imageUrl: '', thumbnailUrl: generated.thumbnailUrl, rawModelUrl: '' },
{ imageUrl: '', thumbnailUrl: undefined, rawModelUrl: '' },
],
)
})
it('falls back to compact custom-cell storage when localStorage quota fails', () => {
const writes = []
global.window = {
localStorage: {
setItem(key, value) {
writes.push({ key, value: JSON.parse(value) })
if (writes.length === 1) throw new Error('quota exceeded')
},
},
}
try {
const result = persistCustomCells([
{
id: 'custom-ready',
imageUrl: 'data:image/webp;base64,large',
thumbnailUrl: 'data:image/webp;base64,thumb',
generation: { status: 'success', modelUrl: '/api/3d/local-model/task.glb', rawModelUrl: 'https://signed.example.com/model.glb', message: 'ready' },
},
])
assert.equal(result.stored, true)
assert.equal(result.compacted, true)
assert.equal(result.cells[0].imageUrl, '')
assert.equal(result.cells[0].thumbnailUrl, 'data:image/webp;base64,thumb')
assert.equal(result.cells[0].generation.modelUrl, '/api/3d/local-model/task.glb')
assert.equal(writes.length, 2)
} finally {
delete global.window
}
})
it('keeps compacted custom-cell array identity when storage remains unavailable', () => {
global.window = {
localStorage: {
setItem() {
throw new Error('storage unavailable')
},
},
}
try {
const compacted = [
{
id: 'custom-ready',
imageUrl: '',
previewDropped: true,
generation: { status: 'success', modelUrl: '/api/3d/local-model/task.glb', rawModelUrl: '', message: 'ready' },
},
]
const result = persistCustomCells(compacted)
assert.equal(result.stored, false)
assert.equal(result.compacted, true)
assert.equal(result.cells, compacted)
} finally {
delete global.window
}
})
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

+133
View File
@@ -0,0 +1,133 @@
import { expect, test } from '@playwright/test'
async function prepareWorkbench(page) {
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
caret-color: transparent !important;
}
`,
})
await page.waitForSelector('.studio-window')
await page.locator('.status-toast').evaluate((node) => {
node.style.display = 'none'
}).catch(() => {})
await page.waitForTimeout(450)
}
async function expectSeparated(page, leftSelector, centerSelector, rightSelector) {
const left = await page.locator(leftSelector).boundingBox()
const center = await page.locator(centerSelector).boundingBox()
const right = await page.locator(rightSelector).boundingBox()
expect(left).toBeTruthy()
expect(center).toBeTruthy()
expect(right).toBeTruthy()
expect(left.x + left.width).toBeLessThanOrEqual(center.x)
expect(center.x + center.width).toBeLessThanOrEqual(right.x)
}
async function expectClippedScreenshot(page, selector, name, options = {}) {
const box = await page.locator(selector).boundingBox()
expect(box).toBeTruthy()
const image = await page.screenshot({
animations: 'disabled',
clip: {
x: Math.floor(box.x),
y: Math.floor(box.y),
width: Math.ceil(box.width),
height: Math.ceil(box.height),
},
mask: options.mask || [],
})
expect(image).toMatchSnapshot(name, {
maxDiffPixelRatio: 0.025,
threshold: 0.18,
})
}
test('workbench layout keeps library, stage, and source rail separated', async ({ page }) => {
await page.goto('/')
await prepareWorkbench(page)
await expectSeparated(page, '.selection-shelf', '.stage-zone', '.command-zone')
await expectClippedScreenshot(page, '.studio-window', 'workbench-layout.png', {
mask: [page.locator('.cell-viewer canvas')],
})
})
test('model library drawer renders productized asset cards', async ({ page }) => {
await page.goto('/')
await prepareWorkbench(page)
await page.getByRole('button', { name: 'Library' }).click()
await expect(page.locator('.drawer-library')).toBeVisible()
await expect(page.locator('.asset-library-card').first()).toBeVisible()
await expect(page.locator('.drawer-library')).toContainText('Generated & Imported Assets')
await expect(page.locator('.drawer-library')).not.toContainText('Organelle')
await expectClippedScreenshot(page, '.drawer-library', 'asset-library-drawer.png', {
mask: [page.locator('.asset-preview-frame img')],
})
})
test('demo mode uses a clean presentation surface', async ({ page }) => {
await page.goto('/')
await prepareWorkbench(page)
await page.getByRole('button', { name: 'Demo' }).click()
await expect(page.locator('.workbench-v2.demo-mode')).toBeVisible()
await expect(page.locator('.demo-exit-button')).toBeVisible()
await expect(page.locator('.selection-shelf')).toBeHidden()
await expect(page.locator('.command-zone')).toBeHidden()
await page.addStyleTag({
content: '.workbench-v2.demo-mode .cell-viewer canvas { opacity: 0 !important; }',
})
await expectClippedScreenshot(page, '.studio-window', 'demo-mode.png')
})
test('view mode controls change the live viewer state', async ({ page }) => {
await page.goto('/')
await prepareWorkbench(page)
const solidButton = page.getByRole('button', { name: 'Solid view' })
const xrayButton = page.getByRole('button', { name: 'X-Ray layer view' })
const inspectButton = page.getByRole('button', { name: 'Inspect focus view' })
await expect(solidButton).toBeVisible()
await expect(xrayButton).toBeVisible()
await expect(inspectButton).toBeVisible()
await solidButton.click()
await expect(page.locator('.cell-viewer.solid')).toBeVisible()
await expect(page.locator('.stage-status')).toContainText('Solid')
await xrayButton.click()
await expect(page.locator('.cell-viewer.layers')).toBeVisible()
await expect(page.locator('.stage-status')).toContainText('X-Ray')
await inspectButton.click()
await expect(page.locator('.cell-viewer.focus')).toBeVisible()
await expect(page.locator('.stage-status')).toContainText('Inspect')
})
test('inspector explains the selected object instead of generic biology parts', async ({ page }) => {
await page.goto('/')
await prepareWorkbench(page)
await page.getByRole('button', { name: 'Info' }).click()
await expect(page.locator('.inspector-zone.open')).toBeVisible()
await expect(page.locator('.inspector-zone.open')).toContainText('Asset Details')
await expect(page.locator('.inspector-zone.open')).toContainText('Object Description')
await expect(page.locator('.inspector-zone.open')).toContainText('Category')
await expect(page.locator('.inspector-zone.open')).not.toContainText('Organelle')
await expect(page.locator('.inspector-zone.open')).not.toContainText('Plasma Membrane')
await expectClippedScreenshot(page, '.inspector-zone.open', 'asset-inspector.png')
})
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})