chore: import upstream snapshot with attribution
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
*.glb binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.webp binary
|
||||
@@ -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?
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
[](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
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`huangserva/3DCellForge`
|
||||
- 原始仓库:https://github.com/huangserva/3DCellForge
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -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 模型。
|
||||
|
||||
## 演示视频
|
||||
|
||||
[](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 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。
|
||||
|
||||
这些模型从 Khronos 已归档样例仓库远程加载,不打包进本仓库。
|
||||
|
||||
## 安全
|
||||
|
||||
不要把真实 API Key 写进前端代码。密钥只放在 `.env.local`,该文件已被 git 忽略。
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
After Width: | Height: | Size: 50 KiB |
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 9.3 KiB |
@@ -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 |
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)]),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -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 |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function StatusToast({ message }) {
|
||||
return (
|
||||
<div className="status-toast">
|
||||
<span />
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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 || '' }
|
||||
}
|
||||
@@ -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' },
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 || ''))
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 || '')))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function canUseWebGL() {
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
return Boolean(canvas.getContext('webgl2') || canvas.getContext('webgl'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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.`)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 196 KiB |
@@ -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')
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||