commit 88025fdd5c2cd095604cf2412e53c2e162c2b0f4 Author: wehub-resource-sync Date: Mon Jul 13 13:15:38 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..104a274 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..77429f6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +*.glb binary +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05475ea --- /dev/null +++ b/.gitignore @@ -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? diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a9e8865 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e43285 --- /dev/null +++ b/README.md @@ -0,0 +1,185 @@ +# 3D Model Studio + +[English](README.md) | [中文](README.zh-CN.md) + +AI-powered interactive 3D model generation, inspection, and presentation studio. + +3D Model Studio is a React + Three.js prototype for turning uploaded reference images or GLB files into a polished interactive 3D workspace. It supports live WebGL orbit controls, a left model library / center stage / right tools workbench, screenshots, GLB export, collapsed upload history, demo presentation mode, a generation queue, and optional image-to-3D providers for generating real 3D models from uploaded reference images. + +## Demo + +[![3D Model Studio demo](docs/demo/3DCellForge-demo-cover.jpg)](docs/demo/3DCellForge-demo-2026-05-10.mp4) + +Open the demo video: [Demo MP4](docs/demo/3DCellForge-demo-2026-05-10.mp4) + +## Features + +- Interactive model viewer built with React Three Fiber. +- Three-column workbench: Model Library on the left, WebGL stage in the center, asset/generation tools on the right. +- Drag to rotate, scroll to zoom, isolate structure parts, inspect model details, and export the current scene. +- Object-aware inspector with inferred category, source, provider state, material focus, demo value, and tags for vehicles, aircraft, vessels, products, artifacts, and organic specimens. +- Model quality score for generated GLBs, including file size, triangle count, texture count, and demo readiness. +- Demo Mode for screenshots and screen recordings: hides side panels, uses object-aware cinematic camera paths, and shows a clean presentation overlay. +- Productized Model Library drawer with source thumbnails, provider/status, task id, GLB URL actions, comparison, and delete controls. +- Saved Assets stays collapsed by default, while the active generated/imported asset stays pinned and clickable. +- Generated/imported models are restored after refresh through IndexedDB, with localStorage as a compact fallback. +- Generic part detail drawer, asset references, comparison panel, notes, gallery actions, logs, saved projects, and a compact generation queue. +- Hyper3D, Tripo, Fal.ai, Hunyuan3D, JS Depth, and Local GLB generation/import modes. +- Cached demo GLB models for offline-friendly screenshots and demos. +- Auxiliary Khronos glTF reference models for GLB loader and PBR material checks. +- API key stays server-side in `.env.local`; it is never exposed to the frontend bundle. + +## Tech Stack + +- React +- Vite +- Three.js +- React Three Fiber +- Drei +- Framer Motion +- Tripo API optional backend +- Fal.ai optional backend +- Hunyuan3D local API optional backend + +## Quick Start + +```bash +npm install +npm run dev +``` + +Open the Vite URL shown in the terminal. + +## Workbench Workflow + +The default screen is intentionally quiet: + +- Pick the active generated/imported asset from the left `Model Library` rail. +- Earlier generated/imported models are tucked under `Saved Assets` until expanded. +- Use the right `Asset Source` rail to choose the generation provider or import a local `.glb` / `.gltf`. +- Watch upload/generation/import state in the left `Generation Queue` panel. +- Click `Info` or `Inspect` only when you need the part detail drawer. +- Open top-nav `Library` for the full asset catalog with previews, provider state, task ids, GLB URL copy, provider comparison, and deletion. +- Click `Demo` in the top navigation to enter a clean presentation mode for screenshots and recordings. +- Check the quality card on the stage before recording; low scores usually mean the source image or provider result is not demo-ready. +- Demo animation adapts to the model name and metadata: cars use a road push-in, aircraft use a flight pass, ships/carriers use a naval cruise, and organic/specimen assets use a studio orbit. + +Useful validation commands: + +```bash +npm run lint +npm run build +npm run test +npm run test:visual +``` + +`npm run test:visual` runs Playwright layout and screenshot regression checks for the workbench, the Model Library drawer, and Demo Mode. Use `npm run test:visual:update` only when an intentional UI change needs new screenshot baselines. + +## Optional Image-to-3D Backend + +To enable image-to-3D generation, create `.env.local`: + +```bash +cp .env.example .env.local +``` + +Then set: + +```bash +TRIPO_API_KEY=your_tripo_key +FAL_API_KEY=your_fal_key +RODIN_API_KEY=your_rodin_api_key +OPENAI_API_KEY=your_openai_key +API_HOST=127.0.0.1 +``` + +`OPENAI_API_KEY` enables optional image understanding through `/api/3d/analyze`. When configured, uploads are classified by vision into asset type, material focus, inspection notes, scene profile, tags, and a better image-to-3D prompt. Without it, the app keeps using local filename/metadata heuristics. + +For Hunyuan3D local backup mode, start your local Hunyuan3D API server and set: + +```bash +HUNYUAN_API_BASE=http://127.0.0.1:8081 +HUNYUAN_CREATE_PATH=/send +HUNYUAN_STATUS_PATH=/status +``` + +The 3D generation backend supports these provider paths: + +```text +Hyper3D Hyper3D Rodin cloud generation only (default) +Tripo Tripo cloud generation only +Fal Fal.ai queue generation; model is selected in Settings +Auto Hyper3D first, then Tripo, Fal, Hunyuan, and JS Depth backup +Hunyuan Local Hunyuan3D generation only +``` + +The upload panel exposes the full generation mode choice before picking a file: + +```text +Hyper3D Hyper3D Rodin GLB generation +Tripo Tripo cloud GLB generation +Fal Fal.ai queue GLB generation +Hunyuan Local Hunyuan3D GLB generation +JS Depth Browser-side image relief with layered PNG fallback +Auto Hyper3D, Tripo, Fal, Hunyuan, then JS Depth fallback +Local GLB Import an existing .glb or self-contained .gltf +``` + +Tripo uploads use the current STS object-storage flow (`/upload/sts/token`) before creating an `image_to_model` task. +Fal uploads use the official `@fal-ai/client` storage and queue APIs. Supported Fal models are Hunyuan3D v2, TRELLIS, TripoSR, Tripo3D v2.5, and Hyper3D Rodin. Pick the active Fal model in `Settings`. +Rodin uploads use Hyper3D's multipart `/rodin` task API, then poll `/status` and cache the GLB returned by `/download`. +Generated GLBs are cached by the Node backend under `.generated-models/`, so later views use the local copy instead of temporary provider URLs. +The frontend model library is saved in IndexedDB, so successful generated/imported model records survive page refreshes. + +You can also import a local `.glb` or self-contained `.gltf` from the `New Upload` button. Imported models become custom workspace models and are served from the same local cache. + +Expected Hunyuan3D local API shape: + +```text +POST /send +GET /status/:uid +``` + +The status response can return either a remote model URL or a base64 GLB field such as `model_base64` / `glb_base64`. Base64 GLBs are cached under `.generated-models/` and served by the Node backend. + +Start the backend: + +```bash +npm run dev:api +``` + +Then start the frontend: + +```bash +npm run dev +``` + +The frontend talks to the local Node backend at `http://127.0.0.1:8787` by default. + +## Demo Models + +The repository includes cached generated GLB files under: + +```text +public/generated-models/ +``` + +These make the demo usable without spending API credits on every run. + +## Reference Models + +The Library panel includes remote Khronos glTF Sample Models as auxiliary references for material and loader checks: + +- Transmission Test, CC0, Adobe via Khronos. +- Transmission Roughness Test, CC-BY 4.0, Ed Mackey / Analytical Graphics via Khronos. +- Mosquito In Amber, CC-BY 4.0, Loic Norgeot / Geoffrey Marchal / Sketchfab via Khronos. + +These are loaded from the archived Khronos sample repository and are not bundled into this repo. + +## Security + +Do not put real API keys in frontend code. Keep secrets in `.env.local`, which is ignored by git. + +## License + +MIT diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..81f301f --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`huangserva/3DCellForge` +- 原始仓库:https://github.com/huangserva/3DCellForge +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..dd82314 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,185 @@ +# 3D Model Studio + +[English](README.md) | [中文](README.zh-CN.md) + +AI 驱动的交互式 3D 模型生成、检查和演示工作台。 + +3D Model Studio 是一个 React + Three.js 原型,用于把上传图片或 GLB 文件变成可交互的 3D 模型工作区。它支持 WebGL 拖拽旋转、滚轮缩放、左侧模型库 / 中央 3D 舞台 / 右侧工具区、截图、GLB 导出、历史上传默认收起、Demo 演示模式、生成队列,以及通过 Hyper3D / Tripo / Fal.ai / Hunyuan3D / JS Depth / 本地模型导入生成或加载 3D 模型。 + +## 演示视频 + +[![3D Model Studio 演示视频](docs/demo/3DCellForge-demo-cover.jpg)](docs/demo/3DCellForge-demo-2026-05-10.mp4) + +打开视频文件:[演示 MP4](docs/demo/3DCellForge-demo-2026-05-10.mp4) + +## 功能 + +- 基于 React Three Fiber 的交互式模型查看器。 +- 三栏工作台:左侧 Model Library,中间 WebGL 主舞台,右侧素材和生成工具。 +- 支持拖拽旋转、滚轮缩放、结构隔离、部件 Inspect 和场景导出。 +- 对象级说明面板会根据资产名称和生成元数据推断类别、来源、Provider 状态、材质重点、演示价值和标签,覆盖车辆、飞机、船舰、产品、文物和有机标本。 +- 模型质量评分会展示 GLB 文件大小、三角面数、贴图数量和演示可用性。 +- Demo Mode 会隐藏左右工具区、根据物体类型使用不同运镜,并显示干净的演示信息层,适合截图和录屏。 +- Model Library 抽屉升级为资产库视图,包含源图预览、Provider / 状态、任务 ID、GLB URL 操作、Provider 对比和删除入口。 +- Saved Assets 默认收起,当前激活的生成 / 导入资产会固定显示并可直接点击打开。 +- 生成 / 导入成功的模型会写入 IndexedDB,刷新页面后会自动恢复;localStorage 只做轻量兜底。 +- 自定义上传记录支持删除,并同步清理相关本地数据。 +- 通用部件详情抽屉、素材参考、对比面板、模型笔记、图库操作、日志、项目保存和生成队列。 +- 支持 Hyper3D、Tripo、Fal.ai、Hunyuan3D、JS Depth 和 Local GLB 多种模式。 +- 生成后的 GLB 会缓存到本地,方便后续演示和截图。 +- 内置 Khronos glTF 辅助参考模型,用于检查 GLB 加载和 PBR 材质表现。 +- API Key 只放在服务端 `.env.local`,不会暴露到前端包里。 + +## 技术栈 + +- React +- Vite +- Three.js +- React Three Fiber +- Drei +- Framer Motion +- Tripo API 可选后端 +- Fal.ai 可选后端 +- Hunyuan3D 本地 API 可选后端 + +## 快速开始 + +```bash +npm install +npm run dev +``` + +打开终端里显示的 Vite 地址即可。 + +## 工作台流程 + +默认页面会尽量减少干扰: + +- 左侧 `Model Library` 固定展示当前激活的生成 / 导入资产。 +- 更早生成、导入过的模型会收进 `Saved Assets`,默认折叠。 +- 右侧 `Asset Source` 用来选择生成模式或导入本地 `.glb` / `.gltf`。 +- 左侧 `Generation Queue` 可以查看上传、生成、导入状态,并对失败任务重试。 +- 需要部件说明时,再点击 `Info` 或 `Inspect` 打开详情抽屉。 +- 顶部打开 `Library` 可以查看完整资产库:预览、Provider 状态、任务 ID、GLB URL 复制、Provider 对比和删除。 +- 顶部点击 `Demo` 进入纯展示模式,适合截图、录屏、演示。 +- 录屏前先看主舞台的质量评分;分数低通常说明源图或生成结果还不适合演示。 +- Demo 动画会根据模型名称和元数据切换:汽车走低机位推进,飞机走飞行掠过,航母 / 船走侧向巡航,有机 / 标本类资产走工作室环绕。 + +常用验证命令: + +```bash +npm run lint +npm run build +npm run test +npm run test:visual +``` + +`npm run test:visual` 会运行 Playwright 布局和截图回归,覆盖工作台、Model Library 抽屉和 Demo Mode。只有确认 UI 改动是预期变化时,才运行 `npm run test:visual:update` 更新截图基线。 + +## 可选 Image-to-3D 后端 + +创建 `.env.local`: + +```bash +cp .env.example .env.local +``` + +然后设置: + +```bash +TRIPO_API_KEY=your_tripo_key +FAL_API_KEY=your_fal_key +RODIN_API_KEY=your_rodin_api_key +OPENAI_API_KEY=your_openai_key +API_HOST=127.0.0.1 +``` + +`OPENAI_API_KEY` 会启用可选的图片理解接口 `/api/3d/analyze`。配置后,上传图片会先被视觉模型识别为资产类型、材质重点、检查重点、展示场景、标签,并生成更适合 image-to-3D 的提示词。没配置时,应用继续使用本地文件名和元数据规则,不会影响基础上传和生成。 + +如需启用 Hunyuan3D 本地备用模式,先启动你的 Hunyuan3D API 服务,再设置: + +```bash +HUNYUAN_API_BASE=http://127.0.0.1:8081 +HUNYUAN_CREATE_PATH=/send +HUNYUAN_STATUS_PATH=/status +``` + +3D 生成后端支持这些路径: + +```text +Hyper3D 只走 Hyper3D Rodin 云端生成,默认模式 +Tripo 只走 Tripo 云端生成 +Fal 只走 Fal.ai 队列生成,具体模型在 Settings 里选择 +Auto 先 Hyper3D,再 Tripo、Fal、Hunyuan,最后 JS Depth 兜底 +Hunyuan 只走本地 Hunyuan3D +``` + +上传面板支持这些模式: + +```text +Hyper3D Hyper3D Rodin GLB 生成 +Tripo Tripo 云端 GLB 生成 +Fal Fal.ai 队列 GLB 生成 +Hunyuan 本地 Hunyuan3D GLB 生成 +JS Depth 浏览器侧图片深度浮雕,WebGL 不可用时降级到透明 PNG 分层 +Auto Hyper3D -> Tripo -> Fal -> Hunyuan -> JS Depth 依次降级 +Local GLB 导入已有 .glb 或自包含 .gltf +``` + +Tripo 上传使用当前 STS 对象存储流程,然后创建 `image_to_model` 任务。生成后的 GLB 会被 Node 后端缓存到 `.generated-models/`,后续展示优先使用本地副本。 +Fal 上传使用官方 `@fal-ai/client` 的 storage 和 queue API。当前支持 Hunyuan3D v2、TRELLIS、TripoSR、Tripo3D v2.5 和 Hyper3D Rodin,具体 Fal 模型在 `Settings` 里选择。 +Rodin 上传使用 Hyper3D 的 multipart `/rodin` 任务接口,然后轮询 `/status` 并通过 `/download` 下载和缓存 GLB。 +前端模型库会保存到 IndexedDB,所以生成或导入成功的模型记录刷新后仍会恢复。 + +也可以从 `New Upload` 入口导入本地 `.glb` 或自包含 `.gltf`,导入后会成为自定义工作区模型。 + +Hunyuan3D 本地 API 预期形式: + +```text +POST /send +GET /status/:uid +``` + +状态接口可以返回远程模型 URL,也可以返回 `model_base64` / `glb_base64` 这类 base64 GLB 字段。base64 GLB 会被缓存到 `.generated-models/` 并由 Node 后端提供访问。 + +启动后端: + +```bash +npm run dev:api +``` + +启动前端: + +```bash +npm run dev +``` + +默认情况下,前端会访问本地 Node 后端 `http://127.0.0.1:8787`。 + +## Demo 模型 + +仓库内置了一些缓存 GLB: + +```text +public/generated-models/ +``` + +这些模型可以让项目在不消耗 API credits 的情况下直接用于演示。 + +## 参考模型 + +Library 面板内置了远程 Khronos glTF Sample Models 作为辅助参考,用于检查材质和 GLB 加载: + +- Transmission 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 diff --git a/docs/demo/3DCellForge-demo-2026-05-10.mp4 b/docs/demo/3DCellForge-demo-2026-05-10.mp4 new file mode 100644 index 0000000..edff49e Binary files /dev/null and b/docs/demo/3DCellForge-demo-2026-05-10.mp4 differ diff --git a/docs/demo/3DCellForge-demo-cover.jpg b/docs/demo/3DCellForge-demo-cover.jpg new file mode 100644 index 0000000..8f2cd41 Binary files /dev/null and b/docs/demo/3DCellForge-demo-cover.jpg differ diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..7023ace --- /dev/null +++ b/eslint.config.js @@ -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, + }, + }, +]) diff --git a/index.html b/index.html new file mode 100644 index 0000000..b9541a4 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + 3D Model Studio + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7b0a8ae --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3259 @@ +{ + "name": "model-studio-3d", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "model-studio-3d", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@fal-ai/client": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@fal-ai/client/-/client-1.10.1.tgz", + "integrity": "sha512-c3AVeH31OioiI2J1BfW8Cryi1DhUYldnY3X35nv6xLMq3fU2NQOo+eYaR5mL2O8MoHHh+HzXdQuIyanIyeq+ug==", + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^3.0.0-beta2", + "eventsource-parser": "^1.1.2", + "robot3": "^0.4.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/@msgpack/msgpack": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", + "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.128.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz", + "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-three/drei": { + "version": "10.7.7", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", + "integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^3.1.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.8.3", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.4", + "tunnel-rat": "^0.1.2", + "use-sync-external-store": "^1.4.0", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19", + "react-dom": "^19", + "three": ">=0.159" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz", + "integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/postprocessing": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@react-three/postprocessing/-/postprocessing-3.0.4.tgz", + "integrity": "sha512-e4+F5xtudDYvhxx3y0NtWXpZbwvQ0x1zdOXWTbXMK6fFLVDd4qucN90YaaStanZGS4Bd5siQm0lGL/5ogf8iDQ==", + "license": "MIT", + "dependencies": { + "maath": "^0.6.0", + "n8ao": "^1.9.4", + "postprocessing": "^6.36.6" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19.0", + "three": ">= 0.156.0" + } + }, + "node_modules/@react-three/postprocessing/node_modules/maath": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.6.0.tgz", + "integrity": "sha512-dSb2xQuP7vDnaYqfoKzlApeRcR2xtN8/f7WV/TMAkBC8552TwTLtOO0JTcSygkYMjNDPoo6V01jTw/aPi4JrMw==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.144.0", + "three": ">=0.144.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz", + "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz", + "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/camera-controls": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", + "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.5.1" + }, + "peerDependencies": { + "three": ">=0.126.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.353", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventsource-parser": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.2.tgz", + "integrity": "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==", + "license": "MIT", + "engines": { + "node": ">=14.18" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hls.js": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", + "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", + "license": "Apache-2.0" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", + "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/n8ao": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/n8ao/-/n8ao-1.10.1.tgz", + "integrity": "sha512-hhI1pC+BfOZBV1KMwynBrVlIm8wqLxj/abAWhF2nZ0qQKyzTSQa1QtLVS2veRiuoBQXojxobcnp0oe+PUoxf/w==", + "license": "ISC", + "peerDependencies": { + "postprocessing": ">=6.30.0", + "three": ">=0.137" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postprocessing": { + "version": "6.39.1", + "resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.1.tgz", + "integrity": "sha512-R2dG2zy+BAx3USl5EHw+PvnrlbT5PKnZVp3se0HCR0pWH8WQdh742yNG4YWOsq6c0bFpffk0Gd2RqPeoP/wKng==", + "license": "Zlib", + "peerDependencies": { + "three": ">= 0.168.0 < 0.185.0" + } + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/robot3": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/robot3/-/robot3-0.4.1.tgz", + "integrity": "sha512-hzjy826lrxzx8eRgv80idkf8ua1JAepRc9Efdtj03N3KNJuznQCPlyCJ7gnUmDFwZCLQjxy567mQVKmdv2BsXQ==", + "license": "BSD-2-Clause" + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", + "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.128.0", + "@rolldown/pluginutils": "1.0.0-rc.18" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-x64": "1.0.0-rc.18", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", + "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + }, + "node_modules/three-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/undici": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.2.0.tgz", + "integrity": "sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/vite": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", + "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.0-rc.18", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..11e7568 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 0000000..bf16889 --- /dev/null +++ b/playwright.config.mjs @@ -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, + }, +}) diff --git a/public/epithelial_cell_3d_tripo_input.png b/public/epithelial_cell_3d_tripo_input.png new file mode 100644 index 0000000..6278f8e Binary files /dev/null and b/public/epithelial_cell_3d_tripo_input.png differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/generated-models/tripo-epithelial-cell-test.glb b/public/generated-models/tripo-epithelial-cell-test.glb new file mode 100644 index 0000000..59504aa Binary files /dev/null and b/public/generated-models/tripo-epithelial-cell-test.glb differ diff --git a/public/generated-models/tripo-plant-cell-test.glb b/public/generated-models/tripo-plant-cell-test.glb new file mode 100644 index 0000000..7b09eed Binary files /dev/null and b/public/generated-models/tripo-plant-cell-test.glb differ diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..2454d87 --- /dev/null +++ b/server.mjs @@ -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) +} diff --git a/server/config.mjs b/server/config.mjs new file mode 100644 index 0000000..1d25f27 --- /dev/null +++ b/server/config.mjs @@ -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) +} diff --git a/server/http-utils.mjs b/server/http-utils.mjs new file mode 100644 index 0000000..663873d --- /dev/null +++ b/server/http-utils.mjs @@ -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 + } +} diff --git a/server/logger.mjs b/server/logger.mjs new file mode 100644 index 0000000..df4037c --- /dev/null +++ b/server/logger.mjs @@ -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)]), + ) +} diff --git a/server/model-store.mjs b/server/model-store.mjs new file mode 100644 index 0000000..6243916 --- /dev/null +++ b/server/model-store.mjs @@ -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) +} diff --git a/server/object-utils.mjs b/server/object-utils.mjs new file mode 100644 index 0000000..add0cf8 --- /dev/null +++ b/server/object-utils.mjs @@ -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)) + } +} diff --git a/server/providers/fal.mjs b/server/providers/fal.mjs new file mode 100644 index 0000000..da6b236 --- /dev/null +++ b/server/providers/fal.mjs @@ -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 +} diff --git a/server/providers/hunyuan.mjs b/server/providers/hunyuan.mjs new file mode 100644 index 0000000..6af571c --- /dev/null +++ b/server/providers/hunyuan.mjs @@ -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 + })) +} diff --git a/server/providers/rodin.mjs b/server/providers/rodin.mjs new file mode 100644 index 0000000..d67a50a --- /dev/null +++ b/server/providers/rodin.mjs @@ -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 + })) +} diff --git a/server/providers/tripo.mjs b/server/providers/tripo.mjs new file mode 100644 index 0000000..db0d724 --- /dev/null +++ b/server/providers/tripo.mjs @@ -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() +} diff --git a/server/providers/vision.mjs b/server/providers/vision.mjs new file mode 100644 index 0000000..e0af4de --- /dev/null +++ b/server/providers/vision.mjs @@ -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 + })) +} diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..e989696 --- /dev/null +++ b/src/App.css @@ -0,0 +1,5486 @@ +.studio-shell { + min-height: 100vh; + padding: 18px; + color: #2f2b25; + background: + linear-gradient(180deg, #f7f8f6 0%, #eef3f4 100%), + #f7f8f6; +} + +.studio-window { + position: relative; + --header-height: 58px; + width: min(1036px, calc(100vw - 36px)); + height: min(586px, calc(100vh - 36px)); + min-height: 560px; + margin: 0 auto; + overflow: hidden; + border: 1px solid #b8c6cc; + border-radius: 8px; + background: #f3eddf; + box-shadow: + 0 26px 70px rgba(62, 70, 73, 0.18), + inset 0 0 0 1px rgba(255, 255, 255, 0.64); +} + +.studio-header { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: 18px; + height: var(--header-height); + padding: 8px 16px; + border-bottom: 1px solid rgba(117, 103, 78, 0.14); + background: rgba(245, 238, 224, 0.74); +} + +.studio-brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.studio-brand strong { + display: block; + color: #221c17; + font-family: Georgia, 'Times New Roman', serif; + font-size: 24px; + font-weight: 500; + line-height: 1; +} + +.studio-brand span { + display: block; + margin-top: 5px; + color: #7c55b9; + font-family: 'Bradley Hand', 'Segoe Print', cursive; + font-size: 12px; +} + +.brand-mark { + display: grid; + place-items: center; + width: 46px; + height: 46px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.46); +} + +.studio-nav { + display: flex; + align-items: center; + gap: 18px; +} + +.studio-nav button, +.profile-button, +.demo-exit-button, +.stage-toolbar button, +.viewer-controls button, +.cell-row, +.cell-delete, +.recent-toggle, +.organelle-row, +.micro-card, +.add-image, +.compare-box, +.detail-fav, +.mini-toggle, +.stage-label, +.workspace-drawer button { + border: 0; + color: inherit; + background: transparent; + font: inherit; + cursor: pointer; +} + +.studio-nav button { + display: grid; + justify-items: center; + gap: 4px; + min-width: 42px; + padding: 4px 5px; + border-radius: 7px; + color: #4f4740; + font-size: 10px; +} + +.studio-nav button.active, +.profile-button.active { + color: #2f6b8a; + background: rgba(228, 242, 248, 0.85); +} + +.profile-button { + display: inline-flex; + align-items: center; + gap: 8px; + height: 34px; + padding: 0 9px; + border: 1px solid rgba(91, 112, 127, 0.22); + border-radius: 999px; + color: #855aa7; + background: rgba(255, 255, 255, 0.5); +} + +.workspace-drawer { + position: absolute; + z-index: 12; + top: 66px; + right: 16px; + width: 330px; + padding: 13px; + border: 1px solid rgba(91, 112, 127, 0.2); + border-radius: 8px; + background: rgba(252, 248, 239, 0.94); + box-shadow: 0 18px 42px rgba(55, 48, 36, 0.16); + backdrop-filter: blur(14px); +} + +.workspace-drawer header { + display: grid; + grid-template-columns: 1fr auto; + gap: 12px; + align-items: start; +} + +.workspace-drawer strong { + display: block; + color: #251f18; + font-family: Georgia, 'Times New Roman', serif; + font-size: 17px; + font-weight: 500; +} + +.workspace-drawer header span { + display: block; + margin-top: 5px; + color: #71675d; + font-size: 11px; + line-height: 1.35; +} + +.workspace-drawer header > button { + display: grid; + place-items: center; + width: 26px; + height: 26px; + border: 1px solid rgba(112, 101, 84, 0.2); + border-radius: 8px; + background: rgba(255, 255, 255, 0.54); +} + +button:disabled, +.workspace-drawer button:disabled, +.stage-toolbar button:disabled { + cursor: not-allowed; + opacity: 0.46; +} + +.drawer-content { + display: grid; + gap: 10px; + max-height: 350px; + margin-top: 12px; + overflow: auto; + padding-right: 2px; +} + +.drawer-copy { + margin: 0; + color: #62594f; + font-size: 11px; + line-height: 1.45; +} + +.gallery-hero, +.uploaded-tile, +.drawer-row, +.compare-card, +.settings-row, +.reference-section, +.reference-card { + border: 1px solid rgba(119, 104, 76, 0.14); + border-radius: 8px; + background: rgba(255, 255, 255, 0.42); +} + +.gallery-hero, +.uploaded-tile, +.drawer-row { + display: grid; + grid-template-columns: 34px 1fr; + align-items: center; + gap: 9px; + padding: 9px; +} + +.gallery-hero strong, +.uploaded-tile strong, +.drawer-row strong, +.compare-card strong, +.reference-section > strong, +.reference-card strong, +.library-card strong, +.settings-row strong { + display: block; + color: #2d261f; + font-size: 11px; + line-height: 1.2; +} + +.gallery-hero > div > span, +.uploaded-tile small, +.drawer-row > span:not(.cell-thumb) small, +.compare-card small, +.reference-card small, +.library-card small, +.settings-row small { + display: block; + margin-top: 3px; + color: #81776b; + font-size: 10px; + line-height: 1.3; +} + +.drawer-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.drawer-actions.three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.drawer-primary, +.drawer-secondary, +.cell-chip-grid button, +.segmented button { + min-height: 30px; + padding: 7px 9px; + border: 1px solid rgba(112, 101, 84, 0.18); + border-radius: 8px; + color: #4e453d; + background: rgba(255, 255, 255, 0.5); + font-size: 10px; + cursor: pointer; +} + +.drawer-primary { + color: #23556f; + border-color: rgba(88, 151, 190, 0.3); + background: rgba(228, 242, 248, 0.78); +} + +.drawer-secondary.full { + width: 100%; +} + +.drawer-secondary.danger { + color: #8e3b3d; + border-color: rgba(173, 75, 80, 0.28); + background: rgba(252, 235, 234, 0.72); +} + +.uploaded-tile span { + width: 34px; + height: 34px; + border-radius: 8px; + background: var(--upload-preview) center / cover; +} + +.drawer-list { + display: grid; + gap: 6px; +} + +.reference-section { + display: grid; + gap: 8px; + padding: 9px; +} + +.reference-section > span { + color: #71675d; + font-size: 10px; + line-height: 1.35; +} + +.reference-grid { + display: grid; + gap: 7px; +} + +.reference-card { + display: grid; + grid-template-columns: 34px 1fr; + align-items: center; + gap: 9px; + min-height: 50px; + padding: 8px; + color: #4f4740; + text-align: left; + cursor: pointer; +} + +.reference-card.active { + border-color: rgba(87, 151, 190, 0.32); + background: rgba(220, 238, 247, 0.82); +} + +.drawer-row { + width: 100%; + text-align: left; + cursor: pointer; +} + +.gallery-shot-card, +.model-library-card, +.note-archive-row, +.settings-health { + border: 1px solid rgba(92, 124, 139, 0.16); + border-radius: 8px; + background: rgba(255, 255, 255, 0.58); +} + +.gallery-shot-card { + display: grid; + grid-template-columns: 82px minmax(0, 1fr); + gap: 9px; + padding: 8px; +} + +.gallery-shot-preview { + width: 82px; + height: 58px; + overflow: hidden; + border: 1px solid rgba(92, 124, 139, 0.14); + border-radius: 8px; + background: rgba(240, 247, 248, 0.75); +} + +.gallery-shot-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.gallery-shot-body { + min-width: 0; +} + +.gallery-shot-body strong, +.note-archive strong, +.settings-health strong, +.model-library-card strong { + display: block; + overflow: hidden; + color: #1c2830; + font-size: 11px; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gallery-shot-body small, +.note-archive small, +.settings-health small, +.model-library-card small { + display: block; + overflow: hidden; + margin-top: 3px; + color: #697983; + font-size: 9px; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gallery-shot-actions, +.model-library-actions { + display: flex; + grid-column: 1 / -1; + gap: 5px; +} + +.gallery-shot-actions button, +.model-library-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 25px; + padding: 5px 7px; + border: 1px solid rgba(112, 101, 84, 0.16); + border-radius: 7px; + color: #40515b; + background: rgba(255, 255, 255, 0.62); + font-size: 9px; +} + +.model-library-list, +.note-archive, +.health-grid { + display: grid; + gap: 7px; +} + +.model-library-card { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + padding: 8px; +} + +.model-library-card.active { + border-color: rgba(70, 157, 185, 0.34); + background: rgba(225, 248, 252, 0.78); +} + +.model-library-actions { + grid-column: auto; + justify-content: end; +} + +.asset-library-drawer { + gap: 12px; +} + +.asset-library-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.asset-library-summary span { + min-width: 0; + padding: 10px; + border: 1px solid rgba(92, 124, 139, 0.15); + border-radius: 8px; + background: rgba(244, 250, 251, 0.72); +} + +.asset-library-summary strong { + color: #1f6582; + font-family: inherit; + font-size: 18px; + font-weight: 820; +} + +.asset-library-summary small { + display: block; + overflow: hidden; + margin-top: 3px; + color: #687984; + font-size: 9px; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} + +.asset-library-section { + display: grid; + gap: 9px; + padding: 10px; + border: 1px solid rgba(92, 124, 139, 0.14); + border-radius: 8px; + background: rgba(255, 255, 255, 0.52); +} + +.asset-section-head, +.starter-assets summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: #5f5278; +} + +.asset-section-head > span, +.starter-assets summary > span { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.asset-section-head strong, +.starter-assets summary span { + overflow: hidden; + color: #263741; + font-family: inherit; + font-size: 12px; + font-weight: 820; + text-overflow: ellipsis; + white-space: nowrap; +} + +.asset-section-head small, +.starter-assets summary small { + flex: 0 0 auto; + color: #72818a; + font-size: 10px; + font-weight: 760; +} + +.asset-card-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 9px; +} + +.asset-card-grid.compact { + grid-template-columns: 1fr; +} + +.asset-library-card { + display: grid; + grid-template-columns: 118px minmax(0, 1fr); + gap: 10px; + min-width: 0; + padding: 10px; + border: 1px solid rgba(92, 124, 139, 0.16); + border-radius: 8px; + background: rgba(255, 255, 255, 0.68); + box-shadow: 0 10px 24px rgba(34, 65, 80, 0.06); +} + +.asset-library-card.compact { + grid-template-columns: 82px minmax(0, 1fr); +} + +.asset-library-card.active { + border-color: rgba(62, 155, 190, 0.46); + background: rgba(227, 247, 252, 0.86); +} + +.asset-library-card.tone-failed { + border-color: rgba(173, 75, 80, 0.24); + background: rgba(253, 241, 241, 0.74); +} + +.asset-preview-frame { + width: 100%; + aspect-ratio: 1 / 0.82; + overflow: hidden; + border: 1px solid rgba(92, 124, 139, 0.14); + border-radius: 8px; + background: + linear-gradient(135deg, rgba(238, 247, 249, 0.92), rgba(255, 250, 239, 0.78)); +} + +.asset-preview-frame img, +.asset-preview-frame .cell-thumb { + width: 100%; + height: 100%; +} + +.asset-preview-frame img { + display: block; + object-fit: cover; +} + +.asset-library-body { + display: grid; + align-content: start; + gap: 8px; + min-width: 0; +} + +.asset-library-title { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: start; +} + +.asset-library-title strong { + display: block; + overflow: hidden; + color: #182832; + font-family: inherit; + font-size: 12px; + font-weight: 830; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.asset-library-title small { + display: block; + overflow: hidden; + margin-top: 3px; + color: #687984; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.asset-status-pill { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 24px; + padding: 0 7px; + border-radius: 999px; + color: #566a75; + background: rgba(238, 242, 243, 0.9); + font-size: 9px; + font-weight: 820; + text-transform: uppercase; +} + +.asset-status-pill.ready, +.asset-status-pill.reference { + color: #397657; + background: rgba(224, 245, 231, 0.92); +} + +.asset-status-pill.failed { + color: #9d4c54; + background: rgba(255, 229, 232, 0.92); +} + +.asset-status-pill.generating, +.asset-status-pill.queued { + color: #2f7190; + background: rgba(224, 243, 249, 0.92); +} + +.asset-stat-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 5px; +} + +.asset-stat-grid span { + min-width: 0; + padding: 6px; + border-radius: 7px; + background: rgba(240, 247, 248, 0.72); +} + +.asset-stat-grid strong, +.asset-stat-grid small { + display: block; + overflow: hidden; + font-family: inherit; + line-height: 1.15; + text-overflow: ellipsis; + white-space: nowrap; +} + +.asset-stat-grid strong { + color: #263741; + font-size: 10px; + font-weight: 820; +} + +.asset-stat-grid small { + margin-top: 2px; + color: #7a858b; + font-size: 8px; + text-transform: uppercase; +} + +.asset-model-url { + display: block; + overflow: hidden; + padding: 7px; + border-radius: 7px; + color: #4e606a; + background: rgba(238, 244, 245, 0.74); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.asset-library-actions { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.asset-library-actions button { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 26px; + padding: 5px 7px; + border: 1px solid rgba(112, 101, 84, 0.16); + border-radius: 7px; + color: #40515b; + background: rgba(255, 255, 255, 0.68); + font-size: 9px; +} + +.asset-library-actions button.danger { + color: #9d4c54; + border-color: rgba(173, 75, 80, 0.22); + background: rgba(255, 239, 240, 0.8); +} + +.asset-library-empty { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 3px 9px; + align-items: center; + padding: 12px; + border: 1px dashed rgba(92, 124, 139, 0.2); + border-radius: 8px; + color: #687984; + background: rgba(244, 250, 251, 0.54); +} + +.asset-library-empty svg { + grid-row: span 2; + color: #528faa; +} + +.asset-library-empty span { + color: #263741; + font-size: 11px; + font-weight: 820; +} + +.asset-library-empty small { + font-size: 10px; +} + +.starter-assets summary { + cursor: pointer; + list-style: none; +} + +.starter-assets summary::-webkit-details-marker { + display: none; +} + +.starter-asset-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; + margin-top: 8px; +} + +.starter-asset { + display: grid; + grid-template-columns: 32px minmax(0, 1fr); + gap: 8px; + align-items: center; + min-width: 0; + padding: 8px; + border: 1px solid rgba(92, 124, 139, 0.14); + border-radius: 8px; + background: rgba(255, 255, 255, 0.56); + text-align: left; +} + +.starter-asset.active { + border-color: rgba(70, 157, 185, 0.34); + background: rgba(225, 248, 252, 0.78); +} + +.starter-asset strong, +.starter-asset small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.starter-asset strong { + color: #263741; + font-size: 10px; +} + +.starter-asset small { + margin-top: 3px; + color: #71818a; + font-size: 9px; +} + +.note-archive { + padding: 8px; +} + +.note-archive-row { + width: 100%; + padding: 8px; + text-align: left; +} + +.note-archive-row.active { + border-color: rgba(70, 157, 185, 0.34); + background: rgba(225, 248, 252, 0.78); +} + +.settings-health { + display: grid; + grid-template-columns: 1fr auto; + gap: 9px; + padding: 10px; +} + +.settings-health .empty-state, +.settings-health .health-grid { + grid-column: 1 / -1; +} + +.health-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.health-grid span { + padding: 8px; + border: 1px solid rgba(92, 124, 139, 0.14); + border-radius: 8px; + background: rgba(255, 255, 255, 0.46); +} + +.health-grid span.healthy { + border-color: rgba(76, 153, 108, 0.24); + background: rgba(232, 245, 235, 0.72); +} + +.health-grid span.missing { + border-color: rgba(173, 131, 75, 0.22); + background: rgba(252, 244, 230, 0.72); +} + +.model-inspector, +.project-manager, +.history-panel { + display: grid; + gap: 9px; + padding: 10px; + border: 1px solid rgba(92, 124, 139, 0.16); + border-radius: 8px; + background: rgba(255, 255, 255, 0.58); +} + +.model-inspector > div:first-child strong, +.project-manager-head strong, +.history-panel strong { + display: block; + color: #1c2830; + font-size: 12px; +} + +.model-inspector > div:first-child small, +.project-manager-head small, +.history-panel small { + display: block; + margin-top: 3px; + color: #697983; + font-size: 10px; +} + +.model-inspector dl { + display: grid; + grid-template-columns: 74px minmax(0, 1fr); + gap: 5px 9px; + margin: 0; + padding: 8px; + border: 1px solid rgba(92, 124, 139, 0.12); + border-radius: 8px; + background: rgba(247, 251, 252, 0.52); +} + +.model-inspector dt, +.model-inspector dd { + min-width: 0; + margin: 0; + overflow: hidden; + font-size: 10px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.model-inspector dt { + color: #697983; +} + +.model-inspector dd { + color: #1f3039; +} + +.project-manager-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; +} + +.project-list, +.history-list { + display: grid; + gap: 7px; +} + +.project-row { + display: grid; + grid-template-columns: 58px minmax(0, 1fr); + gap: 8px; + padding: 8px; + border: 1px solid rgba(92, 124, 139, 0.12); + border-radius: 8px; + background: rgba(255, 255, 255, 0.45); +} + +.project-row img { + width: 58px; + height: 42px; + border-radius: 7px; + object-fit: cover; +} + +.project-row strong, +.project-row small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-row strong { + color: #1c2830; + font-size: 11px; +} + +.project-row small { + margin-top: 4px; + color: #697983; + font-size: 9px; +} + +.project-actions { + display: flex; + grid-column: 1 / -1; + gap: 5px; +} + +.project-actions button, +.history-row { + min-height: 26px; + padding: 5px 7px; + border: 1px solid rgba(112, 101, 84, 0.16); + border-radius: 7px; + color: #40515b; + background: rgba(255, 255, 255, 0.62); + font-size: 9px; +} + +.history-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + width: 100%; + text-align: left; +} + +.history-row.success { + border-color: rgba(76, 153, 108, 0.24); + background: rgba(232, 245, 235, 0.72); +} + +.history-row.failed { + border-color: rgba(173, 75, 80, 0.24); + background: rgba(252, 235, 234, 0.72); +} + +.log-list { + display: grid; + gap: 7px; +} + +.log-row { + display: grid; + gap: 6px; + padding: 9px; + border: 1px solid rgba(92, 124, 139, 0.16); + border-radius: 8px; + background: rgba(255, 255, 255, 0.58); +} + +.log-row.warn { + border-color: rgba(173, 131, 75, 0.22); + background: rgba(252, 244, 230, 0.72); +} + +.log-row.error { + border-color: rgba(173, 75, 80, 0.24); + background: rgba(252, 235, 234, 0.72); +} + +.log-row strong, +.log-row small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.log-row strong { + color: #1c2830; + font-size: 11px; +} + +.log-row small { + margin-top: 3px; + color: #697983; + font-size: 9px; +} + +.log-row code { + display: block; + overflow: hidden; + padding: 7px; + border-radius: 7px; + color: #31434c; + background: rgba(235, 244, 246, 0.68); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 9px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.empty-state { + margin: 0; + padding: 11px; + border: 1px dashed rgba(119, 104, 76, 0.18); + border-radius: 8px; + color: #877b70; + background: rgba(255, 255, 255, 0.28); + font-size: 11px; +} + +.library-grid, +.cell-chip-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.library-card { + display: grid; + gap: 5px; + min-height: 82px; + padding: 9px; + border: 1px solid rgba(119, 104, 76, 0.14); + border-radius: 8px; + background: rgba(255, 255, 255, 0.42); + text-align: left; + cursor: pointer; +} + +.library-card > span { + width: 10px; + height: 10px; + border-radius: 50%; +} + +.library-card.active, +.cell-chip-grid button.active, +.segmented button.active { + border-color: rgba(88, 151, 190, 0.34); + background: rgba(228, 242, 248, 0.8); +} + +.note-editor { + display: grid; + gap: 7px; + color: #665b51; + font-size: 11px; +} + +.note-editor textarea { + min-height: 128px; + resize: vertical; + border: 1px solid rgba(119, 104, 76, 0.18); + border-radius: 8px; + padding: 10px; + color: #3b332c; + background: rgba(255, 255, 255, 0.56); + font: inherit; + line-height: 1.45; +} + +.drawer-meta.inline { + margin-top: 0; +} + +.settings-list { + gap: 8px; +} + +.settings-row { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 10px; + padding: 10px; +} + +.settings-row input { + width: 16px; + height: 16px; + accent-color: #72a4bf; +} + +.settings-select { + min-width: 172px; + max-width: 220px; + padding: 7px 9px; + border: 1px solid rgba(112, 101, 84, 0.2); + border-radius: 8px; + color: #3b3f40; + background: rgba(255, 255, 255, 0.82); + font: inherit; + font-size: 12px; +} + +.settings-select:focus-visible { + outline: 2px solid rgba(82, 142, 170, 0.48); + outline-offset: 2px; +} + +.segmented { + display: inline-grid; + grid-template-columns: repeat(2, auto); + gap: 4px; +} + +.segmented-three { + grid-template-columns: repeat(3, auto); +} + +.provider-segmented { + grid-template-columns: repeat(3, auto); +} + +.compare-drawer-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.compare-card { + display: grid; + gap: 7px; + min-width: 0; + padding: 10px; +} + +.profile-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.profile-stats span { + margin: 0; + padding: 10px; + border: 1px solid rgba(119, 104, 76, 0.14); + border-radius: 8px; + background: rgba(255, 255, 255, 0.42); +} + +.profile-stats strong { + display: block; + color: #2f6b8a; + font-size: 17px; +} + +.profile-stats small { + color: #81776b; + font-size: 10px; +} + +.drawer-meta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + margin-top: 12px; +} + +.drawer-meta span { + min-width: 0; + margin: 0; + padding: 7px 8px; + overflow: hidden; + border: 1px solid rgba(119, 104, 76, 0.12); + border-radius: 8px; + background: rgba(255, 255, 255, 0.42); + color: #5e554b; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-toast { + position: absolute; + z-index: 10; + right: 18px; + bottom: 14px; + display: inline-flex; + align-items: center; + gap: 7px; + max-width: 280px; + padding: 8px 11px; + border: 1px solid rgba(95, 131, 148, 0.2); + border-radius: 8px; + color: #42505a; + background: rgba(246, 251, 252, 0.88); + box-shadow: 0 12px 28px rgba(55, 48, 36, 0.08); + font-size: 11px; + backdrop-filter: blur(12px); +} + +.status-toast span { + width: 7px; + height: 7px; + border-radius: 50%; + background: #5aa77e; + box-shadow: 0 0 0 4px rgba(90, 167, 126, 0.12); +} + +.studio-grid { + display: grid; + grid-template-columns: 176px minmax(430px, 1fr) 206px; + grid-template-rows: minmax(300px, 1fr) 136px; + gap: 10px 12px; + height: calc(100% - var(--header-height)); + padding: 10px 12px 12px; +} + +.left-rail, +.right-rail { + display: grid; + gap: 10px; + min-height: 0; +} + +.left-rail { + grid-row: 1 / 3; + grid-template-rows: minmax(300px, 1fr) 136px; +} + +.right-rail { + grid-column: 3; + grid-row: 1 / 3; + grid-template-rows: 168px 170px minmax(64px, 1fr); +} + +.panel { + min-width: 0; + min-height: 0; + overflow: hidden; + border: 1px solid rgba(119, 104, 76, 0.18); + border-radius: 8px; + background: rgba(252, 247, 235, 0.76); + box-shadow: + 0 12px 30px rgba(75, 64, 48, 0.06), + inset 0 1px 0 rgba(255, 255, 255, 0.72); +} + +.cell-types-panel, +.organelles-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr); +} + +.cell-list, +.organelle-list { + min-height: 0; + overflow: auto; +} + +.notes-panel { + overflow: auto; +} + +.panel-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px 7px; + color: #6f5c89; + font-size: 10px; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.panel-title span { + display: inline-flex; + align-items: center; + gap: 7px; +} + +.cell-list, +.organelle-list { + display: grid; + gap: 5px; + padding: 0 8px 10px; +} + +.cell-row { + display: grid; + grid-template-columns: 34px 1fr auto; + align-items: center; + gap: 8px; + min-height: 45px; + padding: 6px 8px; + border: 1px solid transparent; + border-radius: 8px; + text-align: left; +} + +.cell-row.active { + border-color: rgba(87, 151, 190, 0.28); + color: #2f5971; + background: #dbeef7; +} + +.cell-row strong, +.compare-box strong { + display: block; + color: #2d261f; + font-size: 11px; + font-weight: 700; + line-height: 1.1; +} + +.cell-row small, +.compare-box small { + display: block; + margin-top: 3px; + color: #83796c; + font-size: 9px; +} + +.cell-thumb { + position: relative; + display: inline-grid; + place-items: center; + width: 31px; + height: 31px; + border: 1px solid rgba(95, 83, 66, 0.14); + border-radius: 8px; + background: #fbf8ef; +} + +.cell-thumb > span { + width: 23px; + height: 23px; + border-radius: 50%; + background: + radial-gradient(circle at 42% 38%, #753ca8 0 18%, transparent 19%), + radial-gradient(circle at 60% 58%, #8d58b8 0 18%, transparent 19%), + radial-gradient(circle at 68% 24%, #a84d6e 0 7%, transparent 8%), + radial-gradient(circle at 34% 68%, #5b82c4 0 7%, transparent 8%), + radial-gradient(circle at center, color-mix(in srgb, var(--cell-accent), white 38%) 0 64%, transparent 66%); + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.5); +} + +.cell-thumb.plant > span { + border-radius: 38% 42% 46% 36%; +} + +.cell-thumb.neuron > span { + background: + radial-gradient(circle at center, #8b5cf6 0 23%, transparent 24%), + linear-gradient(25deg, transparent 0 45%, #8b5cf6 46% 52%, transparent 53%), + linear-gradient(-28deg, transparent 0 45%, #8b5cf6 46% 52%, transparent 53%); +} + +.cell-thumb.bacteria > span, +.cell-thumb.muscle > span { + height: 13px; + border-radius: 999px; +} + +.cell-thumb.custom-cell > span { + border-radius: 7px; + background: + linear-gradient(rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.1)), + var(--thumb-image) center / cover, + color-mix(in srgb, var(--cell-accent), white 35%); +} + +.organelle-row { + display: flex; + align-items: center; + gap: 8px; + min-height: 28px; + padding: 5px 8px; + border-radius: 8px; + color: #5e564d; + font-size: 11px; + text-align: left; +} + +.organelle-row.active { + background: rgba(123, 156, 186, 0.15); +} + +.dot, +.color-dot { + display: inline-block; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--dot); +} + +.stage-panel { + position: relative; + overflow: hidden; + border: 1px solid rgba(119, 104, 76, 0.18); + border-radius: 8px; + --render-width: min(650px, 92%); + --render-max-height: 104%; + --render-translate-x: 10px; + --render-translate-y: 8px; + --upload-render-width: min(520px, 82%); + background: + radial-gradient(ellipse at 56% 52%, rgba(255, 255, 255, 0.92) 0 18%, rgba(255, 255, 255, 0.45) 34%, transparent 58%), + linear-gradient(140deg, rgba(255, 251, 239, 0.9), rgba(237, 229, 208, 0.78) 58%, rgba(244, 238, 224, 0.96)), + #f4edde; +} + +.stage-panel::before { + position: absolute; + z-index: 0; + left: 18%; + right: 8%; + bottom: 54px; + height: 24%; + content: ""; + border-radius: 50%; + background: radial-gradient(ellipse at center, rgba(79, 72, 55, 0.14), rgba(79, 72, 55, 0.05) 44%, transparent 72%); + filter: blur(8px); + pointer-events: none; +} + +.stage-panel::after { + position: absolute; + z-index: 0; + inset: 10px; + content: ""; + border-radius: 8px; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.32), transparent 26%, transparent 74%, rgba(255, 255, 255, 0.24)), + radial-gradient(circle at 88% 16%, rgba(114, 164, 191, 0.14), transparent 20%); + pointer-events: none; +} + +.stage-title { + position: absolute; + z-index: 3; + top: 18px; + left: 18px; +} + +.stage-title h1 { + margin: 0; + color: #231b16; + font-family: Georgia, 'Times New Roman', serif; + font-size: 31px; + font-weight: 500; + line-height: 1; + letter-spacing: 0; +} + +.stage-title p { + margin: 8px 0 0; + color: #9a897a; + font-family: Georgia, 'Times New Roman', serif; + font-size: 14px; + font-style: italic; +} + +.cell-viewer { + position: absolute; + inset: 0; + z-index: 1; +} + +.workbench-v2.demo-mode .stage-panel .cell-viewer { + z-index: 2; +} + +.workbench-v2.demo-mode .stage-panel .cell-viewer.webgl-ready.has-glb > .cell-fallback { + display: none; +} + +.cell-viewer > div:not(.cell-fallback) { + position: absolute !important; + inset: 0; + z-index: 1; +} + +.cell-viewer canvas { + cursor: grab; +} + +.cell-viewer canvas:active { + cursor: grabbing; +} + +.cell-viewer.is-isolated { + filter: saturate(1.06) contrast(1.03); +} + +.cell-viewer.solid { + filter: saturate(1.02) contrast(1.02); +} + +.cell-viewer.layers { + filter: saturate(0.88) contrast(1.1); +} + +.cell-viewer.focus { + filter: saturate(1.18) contrast(1.12); +} + +.cell-viewer.layers .upload-render-fallback img, +.cell-viewer.layers .plant-render-fallback img { + opacity: 0.74; + filter: saturate(0.88) contrast(1.14) drop-shadow(0 18px 28px rgba(56, 157, 190, 0.16)); +} + +.cell-viewer.focus .upload-render-fallback img, +.cell-viewer.focus .plant-render-fallback img { + filter: saturate(1.18) contrast(1.13) drop-shadow(0 20px 32px rgba(53, 101, 132, 0.18)); + transform: scale(1.04); +} + +.cell-viewer.layers .upload-render-fallback::after, +.cell-viewer.layers .plant-render-fallback::after, +.cinematic-layer-scene.mode-layers::after { + content: ''; + position: absolute; + inset: 10%; + border-radius: 999px; + pointer-events: none; + background: + linear-gradient(90deg, rgba(67, 158, 186, 0.14) 1px, transparent 1px), + linear-gradient(0deg, rgba(67, 158, 186, 0.12) 1px, transparent 1px); + background-size: 34px 34px; + mask-image: radial-gradient(circle, #000 0 54%, transparent 72%); + opacity: 0.44; +} + +.cinematic-layer-scene.mode-focus { + filter: saturate(1.16) contrast(1.1); +} + +.stage-label { + position: absolute; + z-index: 4; + left: 50%; + top: 78px; + display: inline-flex; + align-items: center; + gap: 7px; + max-width: 190px; + padding: 7px 10px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--label-color), white 50%); + border-radius: 8px; + color: #31271f; + background: rgba(255, 252, 244, 0.72); + box-shadow: 0 10px 26px rgba(70, 59, 43, 0.08); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; + transform: translateX(-50%); + backdrop-filter: blur(10px); +} + +.proof-launcher + .proof-badge + .stage-label, +.proof-launcher.active + .proof-badge + .stage-label { + top: 116px; +} + +.stage-label span { + flex: 0 0 auto; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--label-color); +} + +.stage-status { + position: absolute; + z-index: 4; + left: 18px; + top: 90px; + max-width: 185px; + color: #74685b; + font-size: 10px; +} + +.model-quality-card { + position: absolute; + z-index: 5; + top: 126px; + left: 18px; + display: grid; + gap: 8px; + width: min(260px, 38%); + padding: 10px; + border: 1px solid rgba(75, 139, 166, 0.22); + border-radius: 8px; + background: rgba(255, 255, 255, 0.68); + box-shadow: 0 14px 34px rgba(34, 74, 88, 0.1); + backdrop-filter: blur(16px); +} + +.quality-score { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: center; + gap: 7px; + color: #2f6b8a; +} + +.quality-score strong { + color: #17242b; + font-size: 24px; + line-height: 1; +} + +.quality-score span { + overflow: hidden; + color: #51656f; + font-size: 11px; + font-weight: 780; + text-overflow: ellipsis; + white-space: nowrap; +} + +.quality-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 5px; +} + +.quality-stats span { + min-width: 0; + padding: 6px; + border-radius: 7px; + background: rgba(236, 247, 249, 0.72); +} + +.quality-stats strong, +.quality-stats small { + display: block; + overflow: hidden; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.quality-stats strong { + color: #1f3039; + font-size: 10px; +} + +.quality-stats small { + margin-top: 2px; + color: #6c7d86; + font-size: 8px; + text-transform: uppercase; +} + +.demo-showcase-overlay { + position: absolute; + z-index: 8; + inset: 0; + pointer-events: none; +} + +.presentation-motion-field { + position: absolute; + z-index: 1; + inset: 0; + overflow: hidden; + pointer-events: none; +} + +.presentation-motion-field::before, +.presentation-motion-field::after, +.presentation-motion-field span { + position: absolute; + content: ""; + pointer-events: none; +} + +.presentation-motion-field.road::before { + left: 2%; + right: 2%; + bottom: -10%; + height: 66%; + clip-path: polygon(40% 0, 60% 0, 100% 100%, 0 100%); + background: + repeating-linear-gradient( + 180deg, + rgba(255, 255, 255, 0.92) 0 36px, + transparent 36px 84px + ), + linear-gradient(90deg, transparent 0 38%, rgba(31, 64, 72, 0.34) 38% 39%, transparent 39% 61%, rgba(31, 64, 72, 0.34) 61% 62%, transparent 62%), + linear-gradient(180deg, rgba(128, 154, 158, 0.26), rgba(42, 67, 70, 0.42)); + opacity: 1; + transform: perspective(620px) rotateX(62deg); + transform-origin: 50% 100%; + animation: road-lane-flow 780ms linear infinite; +} + +.presentation-motion-field.road::after { + left: 6%; + right: 6%; + bottom: 20%; + height: 34%; + border-radius: 50%; + background: + linear-gradient(90deg, transparent, rgba(34, 72, 82, 0.16), transparent), + radial-gradient(ellipse at center, rgba(26, 34, 37, 0.24), rgba(26, 34, 37, 0.05) 50%, transparent 72%); + filter: blur(9px); + opacity: 0.86; +} + +.presentation-motion-field.road span { + top: calc(24% + var(--motion-index, 0) * 8%); + width: clamp(120px, 12vw, 260px); + height: 3px; + border-radius: 999px; + background: linear-gradient(90deg, transparent, rgba(42, 104, 128, 0.38), transparent); + filter: blur(0.2px); + opacity: 0.68; + animation: road-speed-line 1.35s ease-in-out infinite; +} + +.presentation-motion-field.road span:nth-child(1) { --motion-index: 0; left: 9%; animation-delay: -0.1s; } +.presentation-motion-field.road span:nth-child(2) { --motion-index: 1; --line-travel: -280px; right: 7%; animation-delay: -0.32s; } +.presentation-motion-field.road span:nth-child(3) { --motion-index: 2; left: 13%; animation-delay: -0.58s; } +.presentation-motion-field.road span:nth-child(4) { --motion-index: 3; --line-travel: -280px; right: 12%; animation-delay: -0.76s; } +.presentation-motion-field.road span:nth-child(5) { --motion-index: 4; left: 6%; animation-delay: -1s; } +.presentation-motion-field.road span:nth-child(6) { --motion-index: 5; --line-travel: -280px; right: 16%; animation-delay: -1.2s; } + +.presentation-motion-field.aircraft::before { + inset: 8% 4% 12%; + background: + radial-gradient(circle at 12% 22%, rgba(255, 255, 255, 0.75) 0 3%, transparent 10%), + radial-gradient(circle at 78% 18%, rgba(255, 255, 255, 0.6) 0 2.5%, transparent 9%), + linear-gradient(135deg, transparent 0 48%, rgba(105, 152, 184, 0.22) 48% 49%, transparent 51%), + linear-gradient(150deg, transparent 0 42%, rgba(126, 98, 183, 0.16) 42% 43%, transparent 45%); + filter: blur(0.4px); + opacity: 0.94; + animation: aircraft-sky-drift 3.8s ease-in-out infinite; +} + +.presentation-motion-field.aircraft span { + left: -26%; + width: 54%; + height: 3px; + border-radius: 999px; + background: linear-gradient(90deg, transparent, rgba(76, 128, 166, 0.42), rgba(255, 255, 255, 0.74), transparent); + transform: rotate(-16deg); + animation: aircraft-contrail-pass 1.8s ease-in-out infinite; +} + +.presentation-motion-field.aircraft span:nth-child(1) { top: 24%; animation-delay: -0.1s; } +.presentation-motion-field.aircraft span:nth-child(2) { top: 34%; animation-delay: -0.42s; opacity: 0.58; } +.presentation-motion-field.aircraft span:nth-child(3) { top: 45%; animation-delay: -0.76s; } +.presentation-motion-field.aircraft span:nth-child(4) { top: 58%; animation-delay: -1.1s; opacity: 0.5; } +.presentation-motion-field.aircraft span:nth-child(5) { top: 68%; animation-delay: -1.44s; } +.presentation-motion-field.aircraft span:nth-child(6) { top: 78%; animation-delay: -1.78s; opacity: 0.45; } + +.presentation-motion-field.vessel::before { + left: -4%; + right: -4%; + bottom: -4%; + height: 52%; + background: + repeating-radial-gradient(ellipse at center, rgba(66, 142, 166, 0.22) 0 2px, transparent 2px 18px), + repeating-linear-gradient(176deg, rgba(75, 150, 174, 0.22) 0 2px, transparent 2px 24px); + border-radius: 50%; + filter: blur(0.5px); + opacity: 0.88; + transform: perspective(640px) rotateX(58deg); + animation: vessel-water-flow 2.4s linear infinite; +} + +.presentation-motion-field.vessel::after { + left: 24%; + right: 24%; + bottom: 26%; + height: 22%; + background: + linear-gradient(95deg, transparent, rgba(255, 255, 255, 0.72), transparent), + linear-gradient(84deg, transparent, rgba(126, 190, 206, 0.52), transparent); + clip-path: polygon(48% 0, 52% 0, 100% 100%, 0 100%); + opacity: 0.86; + filter: blur(5px); + animation: vessel-wake-pulse 2.2s ease-in-out infinite; +} + +.presentation-motion-field.vessel span { + left: 18%; + right: 18%; + height: 1px; + border-radius: 999px; + background: linear-gradient(90deg, transparent, rgba(56, 132, 154, 0.32), transparent); + animation: vessel-wave-slide 2.8s linear infinite; +} + +.presentation-motion-field.vessel span:nth-child(1) { bottom: 17%; animation-delay: -0.1s; } +.presentation-motion-field.vessel span:nth-child(2) { bottom: 22%; animation-delay: -0.42s; opacity: 0.7; } +.presentation-motion-field.vessel span:nth-child(3) { bottom: 27%; animation-delay: -0.76s; } +.presentation-motion-field.vessel span:nth-child(4) { bottom: 32%; animation-delay: -1.1s; opacity: 0.62; } +.presentation-motion-field.vessel span:nth-child(5) { bottom: 37%; animation-delay: -1.44s; } +.presentation-motion-field.vessel span:nth-child(6) { bottom: 42%; animation-delay: -1.78s; opacity: 0.54; } + +.presentation-motion-field.artifact::before { + inset: 0; + background: + radial-gradient(circle at 50% 28%, rgba(255, 199, 110, 0.36), transparent 18%), + radial-gradient(ellipse at 50% 68%, rgba(187, 128, 48, 0.28), transparent 30%), + linear-gradient(180deg, rgba(255, 217, 150, 0.14), transparent 52%); + animation: artifact-spotlight-breathe 4.8s ease-in-out infinite; +} + +.presentation-motion-field.artifact::after { + left: 28%; + right: 28%; + bottom: 17%; + height: 16%; + border-radius: 50%; + background: radial-gradient(ellipse at center, rgba(197, 147, 64, 0.42), rgba(197, 147, 64, 0.08) 48%, transparent 72%); + filter: blur(9px); +} + +.presentation-motion-field.artifact span { + left: 48%; + bottom: 19%; + width: 1px; + height: 52%; + background: linear-gradient(180deg, rgba(255, 236, 190, 0), rgba(255, 206, 117, 0.52), rgba(255, 236, 190, 0)); + transform-origin: 50% 100%; + animation: artifact-light-sweep 6.4s ease-in-out infinite; +} + +.presentation-motion-field.artifact span:nth-child(1) { transform: rotate(-38deg); animation-delay: -0.1s; } +.presentation-motion-field.artifact span:nth-child(2) { transform: rotate(-24deg); animation-delay: -0.8s; opacity: 0.5; } +.presentation-motion-field.artifact span:nth-child(3) { transform: rotate(-10deg); animation-delay: -1.5s; } +.presentation-motion-field.artifact span:nth-child(4) { transform: rotate(12deg); animation-delay: -2.2s; opacity: 0.5; } +.presentation-motion-field.artifact span:nth-child(5) { transform: rotate(26deg); animation-delay: -2.9s; } +.presentation-motion-field.artifact span:nth-child(6) { transform: rotate(40deg); animation-delay: -3.6s; opacity: 0.5; } + +.presentation-motion-field.product::before { + left: 8%; + right: 8%; + bottom: 0; + height: 48%; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(125, 176, 190, 0.2)), + repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.3) 0 1px, transparent 1px 34px); + border-radius: 50% 50% 0 0; + opacity: 0.78; + transform: perspective(760px) rotateX(62deg); + transform-origin: 50% 100%; +} + +.presentation-motion-field.product span, +.presentation-motion-field.specimen span { + left: 12%; + right: 12%; + height: 1px; + border-radius: 999px; + background: linear-gradient(90deg, transparent, rgba(78, 148, 168, 0.24), transparent); + animation: product-scanline-drift 3.2s ease-in-out infinite; +} + +.presentation-motion-field.product span:nth-child(1), +.presentation-motion-field.specimen span:nth-child(1) { top: 26%; animation-delay: -0.1s; } +.presentation-motion-field.product span:nth-child(2), +.presentation-motion-field.specimen span:nth-child(2) { top: 34%; animation-delay: -0.5s; opacity: 0.5; } +.presentation-motion-field.product span:nth-child(3), +.presentation-motion-field.specimen span:nth-child(3) { top: 47%; animation-delay: -0.9s; } +.presentation-motion-field.product span:nth-child(4), +.presentation-motion-field.specimen span:nth-child(4) { top: 59%; animation-delay: -1.3s; opacity: 0.5; } +.presentation-motion-field.product span:nth-child(5), +.presentation-motion-field.specimen span:nth-child(5) { top: 70%; animation-delay: -1.7s; } +.presentation-motion-field.product span:nth-child(6), +.presentation-motion-field.specimen span:nth-child(6) { top: 82%; animation-delay: -2.1s; opacity: 0.45; } + +.presentation-motion-field.specimen::before { + inset: 12% 12% 14%; + background: + radial-gradient(circle at 18% 24%, rgba(98, 185, 171, 0.18) 0 2px, transparent 6px), + radial-gradient(circle at 82% 28%, rgba(130, 95, 185, 0.16) 0 2px, transparent 6px), + radial-gradient(circle at 36% 78%, rgba(218, 117, 128, 0.14) 0 2px, transparent 6px), + repeating-linear-gradient(90deg, rgba(82, 150, 160, 0.12) 0 1px, transparent 1px 36px), + repeating-linear-gradient(0deg, rgba(82, 150, 160, 0.1) 0 1px, transparent 1px 36px); + border-radius: 50%; + opacity: 0.84; + animation: specimen-field-drift 6s ease-in-out infinite; +} + +.demo-showcase-title { + position: absolute; + top: 30px; + left: 34px; + display: grid; + gap: 6px; + max-width: min(620px, 52%); + color: #10202a; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.62); +} + +.demo-showcase-title span { + color: #2f6b8a; + font-size: 11px; + font-weight: 820; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.demo-showcase-title strong { + overflow-wrap: anywhere; + font-size: clamp(30px, 4vw, 62px); + line-height: 0.96; +} + +.demo-showcase-title small { + color: #586c76; + font-size: 14px; + font-weight: 720; +} + +.demo-showcase-title p { + max-width: 560px; + margin: 2px 0 0; + color: #40535c; + font-size: 13px; + font-weight: 680; + line-height: 1.42; +} + +.demo-scene-badges { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 5px; +} + +.demo-scene-badges em { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 4px 8px; + border: 1px solid rgba(75, 139, 166, 0.22); + border-radius: 999px; + color: #285f78; + background: rgba(255, 255, 255, 0.58); + box-shadow: 0 8px 20px rgba(31, 64, 72, 0.08); + font-size: 10px; + font-style: normal; + font-weight: 820; + text-transform: uppercase; + backdrop-filter: blur(10px); +} + +.demo-metric-strip { + position: absolute; + left: 34px; + bottom: 34px; + display: grid; + grid-template-columns: repeat(5, minmax(70px, auto)); + gap: 8px; + max-width: calc(100% - 220px); +} + +.demo-metric-strip span { + min-width: 0; + padding: 10px 12px; + border: 1px solid rgba(80, 135, 154, 0.2); + border-radius: 8px; + background: rgba(255, 255, 255, 0.68); + box-shadow: 0 16px 38px rgba(39, 78, 94, 0.12); + backdrop-filter: blur(18px); +} + +.demo-metric-strip strong, +.demo-metric-strip small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.demo-metric-strip strong { + color: #17242b; + font-size: 18px; +} + +.demo-metric-strip small { + margin-top: 2px; + color: #5d717a; + font-size: 9px; + font-weight: 760; + text-transform: uppercase; +} + +.demo-source-thumb { + position: absolute; + right: 34px; + bottom: 34px; + width: 110px; + padding: 8px; + border: 1px solid rgba(80, 135, 154, 0.18); + border-radius: 8px; + background: rgba(255, 255, 255, 0.7); + box-shadow: 0 16px 38px rgba(39, 78, 94, 0.12); + backdrop-filter: blur(18px); +} + +.demo-source-thumb img { + display: block; + width: 100%; + aspect-ratio: 1; + border-radius: 7px; + object-fit: cover; +} + +.demo-source-thumb span { + display: block; + margin-top: 6px; + color: #5d717a; + font-size: 9px; + font-weight: 780; + text-align: center; + text-transform: uppercase; +} + +.proof-launcher { + position: absolute; + z-index: 5; + right: 170px; + top: 74px; + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 33px; + padding: 8px 11px; + border: 1px solid rgba(70, 116, 180, 0.36); + border-radius: 8px; + color: #294f88; + background: rgba(220, 236, 255, 0.9); + box-shadow: 0 12px 28px rgba(53, 67, 75, 0.1); + font-size: 11px; + font-weight: 700; + cursor: pointer; + backdrop-filter: blur(10px); +} + +.proof-launcher.active { + color: #12305f; + border-color: rgba(45, 91, 170, 0.52); + background: rgba(196, 224, 255, 0.96); +} + +.custom-reference-layer { + position: absolute; + z-index: 4; + left: 18px; + top: 118px; + display: grid; + gap: 6px; + width: 112px; + padding: 7px; + border: 1px solid rgba(88, 151, 190, 0.28); + border-radius: 8px; + color: #294f88; + background: rgba(244, 249, 252, 0.78); + box-shadow: 0 12px 28px rgba(53, 67, 75, 0.1); + pointer-events: none; + backdrop-filter: blur(10px); +} + +.custom-reference-layer img { + width: 100%; + aspect-ratio: 1; + border-radius: 6px; + object-fit: cover; +} + +.custom-reference-layer span { + font-size: 9px; + font-weight: 700; + line-height: 1.2; +} + +.cinematic-viewer { + overflow: hidden; + perspective: 1280px; +} + +.cinematic-layer-scene { + --px: 0; + --py: 0; + position: absolute; + inset: 0; + z-index: 3; + display: grid; + place-items: center; + overflow: hidden; + cursor: grab; + perspective: 1400px; + background: + radial-gradient(circle at 38% 34%, rgba(255, 255, 255, 0.78), transparent 28%), + radial-gradient(circle at 68% 66%, rgba(122, 178, 196, 0.2), transparent 42%), + linear-gradient(135deg, rgba(255, 251, 239, 0.9), rgba(237, 246, 241, 0.58)); +} + +.cinematic-layer-scene:active { + cursor: grabbing; +} + +.cinematic-relief-canvas { + position: absolute !important; + inset: 0; + z-index: 3; + width: 100% !important; + height: 100% !important; +} + +.cinematic-depth-field { + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + transform-style: preserve-3d; +} + +.cinematic-depth-field::before, +.cinematic-depth-field::after { + position: absolute; + inset: 8% 12%; + content: ""; + border-radius: 50%; + background: + radial-gradient(circle at 18% 22%, rgba(132, 80, 184, 0.22) 0 2px, transparent 5px), + radial-gradient(circle at 72% 26%, rgba(108, 164, 198, 0.2) 0 3px, transparent 7px), + radial-gradient(circle at 28% 74%, rgba(223, 112, 70, 0.18) 0 3px, transparent 8px), + radial-gradient(circle at 84% 72%, rgba(125, 176, 92, 0.16) 0 3px, transparent 8px); + opacity: 0.72; + filter: blur(0.2px); + transform: translate3d(calc(var(--px) * -18px), calc(var(--py) * -12px), -120px); +} + +.cinematic-depth-field::after { + inset: 4% 8%; + opacity: 0.28; + transform: translate3d(calc(var(--px) * 28px), calc(var(--py) * 18px), 120px); +} + +.layered-png-stage { + position: relative; + z-index: 3; + width: min(64%, 620px); + aspect-ratio: var(--layer-aspect); + transform: + rotateX(calc(var(--py) * -8deg)) + rotateY(calc(var(--px) * 12deg)) + translate3d(calc(var(--px) * 7px), calc(var(--py) * 5px), 0); + transform-style: preserve-3d; + transition: transform 120ms ease-out; +} + +.layered-png-stage.auto { + animation: layered-stage-orbit 7s ease-in-out infinite; +} + +.layered-png-stage.auto.motion-road { + animation-name: layered-road-drive; + animation-duration: 7.8s; +} + +.layered-png-stage.auto.motion-aircraft { + animation-name: layered-flight-pass; + animation-duration: 7.2s; +} + +.layered-png-stage.auto.motion-vessel { + animation-name: layered-naval-cruise; + animation-duration: 8.6s; +} + +.layered-png-stage.auto.motion-artifact { + animation-name: layered-artifact-turntable; + animation-duration: 9s; +} + +.cinematic-png-layer { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + opacity: var(--layer-opacity); + pointer-events: none; + transform: + translate3d(calc(var(--px) * var(--shift-x)), calc(var(--py) * var(--shift-y)), var(--z)) + scale(var(--scale)); + transform-style: preserve-3d; + transition: transform 120ms ease-out, opacity 180ms ease-out; + user-select: none; +} + +.layer-shadow { + filter: blur(18px) saturate(0.7); + mix-blend-mode: multiply; +} + +.layer-depth { + filter: blur(4px) saturate(0.92); +} + +.layer-rim { + filter: drop-shadow(0 0 10px rgba(108, 164, 198, 0.32)); + mix-blend-mode: screen; +} + +.layer-body { + filter: saturate(1.08) contrast(1.02) drop-shadow(0 30px 48px rgba(65, 74, 84, 0.16)); +} + +.layer-core { + filter: saturate(1.16) contrast(1.06); +} + +.layer-front { + filter: saturate(1.2) brightness(1.04) drop-shadow(0 10px 16px rgba(78, 69, 82, 0.08)); +} + +.layer-particles { + filter: saturate(1.18) drop-shadow(0 8px 16px rgba(82, 73, 95, 0.16)); +} + +.layer-highlight { + mix-blend-mode: screen; + filter: blur(0.4px); +} + +.layered-png-loading { + position: absolute; + left: 50%; + top: 50%; + z-index: 4; + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 34px; + padding: 8px 11px; + border: 1px solid rgba(70, 116, 180, 0.22); + border-radius: 8px; + color: #355269; + background: rgba(255, 252, 244, 0.82); + box-shadow: 0 12px 28px rgba(53, 67, 75, 0.1); + font-size: 10px; + font-weight: 700; + transform: translate(-50%, -50%); + backdrop-filter: blur(10px); +} + +.layered-png-loading span { + width: 8px; + height: 8px; + border-radius: 50%; + background: #72a4bf; + animation: loading-pulse 900ms ease-in-out infinite; +} + +@keyframes relief-drift { + 0%, + 100% { + transform: translate3d(-5px, 4px, 0) scale(1); + } + + 50% { + transform: translate3d(7px, -8px, 0) scale(1.18); + } +} + +@keyframes layered-stage-orbit { + 0%, + 100% { + transform: rotateX(4deg) rotateY(-9deg) translate3d(0, 0, 0); + } + + 50% { + transform: rotateX(-4deg) rotateY(10deg) translate3d(0, -4px, 0); + } +} + +@keyframes layered-road-drive { + 0%, + 100% { + transform: rotateX(3deg) rotateY(-15deg) translate3d(-12px, 5px, 0) scale(0.94); + } + + 48% { + transform: rotateX(-1deg) rotateY(12deg) translate3d(18px, -4px, 60px) scale(1.1); + } + + 70% { + transform: rotateX(-2deg) rotateY(6deg) translate3d(8px, -2px, 82px) scale(1.13); + } +} + +@keyframes layered-flight-pass { + 0%, + 100% { + transform: rotateX(8deg) rotateY(-18deg) rotateZ(9deg) translate3d(-38px, 16px, -30px) scale(0.94); + } + + 48% { + transform: rotateX(-3deg) rotateY(18deg) rotateZ(-12deg) translate3d(42px, -12px, 70px) scale(1.08); + } +} + +@keyframes layered-naval-cruise { + 0%, + 100% { + transform: rotateX(2deg) rotateY(-12deg) translate3d(-34px, 3px, 0) scale(0.96); + } + + 50% { + transform: rotateX(1deg) rotateY(10deg) translate3d(34px, -3px, 54px) scale(1.06); + } +} + +@keyframes layered-artifact-turntable { + 0%, + 100% { + transform: rotateX(1deg) rotateY(-18deg) translate3d(-10px, 4px, 0) scale(0.96); + } + + 50% { + transform: rotateX(-2deg) rotateY(18deg) translate3d(12px, -4px, 58px) scale(1.08); + } +} + +@keyframes road-lane-flow { + from { + background-position: 0 -80px, 0 0, 0 0; + } + + to { + background-position: 0 80px, 0 0, 0 0; + } +} + +@keyframes road-speed-line { + 0% { + opacity: 0; + transform: translateX(0) scaleX(0.45); + } + + 18%, + 72% { + opacity: 0.58; + } + + 100% { + opacity: 0; + transform: translateX(var(--line-travel, 280px)) scaleX(1.28); + } +} + +@keyframes aircraft-sky-drift { + 0%, + 100% { + transform: translate3d(-18px, 12px, 0) scale(1); + } + + 50% { + transform: translate3d(18px, -12px, 0) scale(1.04); + } +} + +@keyframes aircraft-contrail-pass { + 0% { + opacity: 0; + transform: translate3d(-14vw, 8px, 0) rotate(-16deg) scaleX(0.72); + } + + 18%, + 70% { + opacity: 0.68; + } + + 100% { + opacity: 0; + transform: translate3d(136vw, -34px, 0) rotate(-16deg) scaleX(1.1); + } +} + +@keyframes vessel-water-flow { + from { + background-position: 0 0, 0 0; + } + + to { + background-position: -42px 34px, 72px 0; + } +} + +@keyframes vessel-wake-pulse { + 0%, + 100% { + opacity: 0.28; + transform: scaleX(0.62); + } + + 50% { + opacity: 0.76; + transform: scaleX(1.08); + } +} + +@keyframes vessel-wave-slide { + from { + transform: translateX(-8%); + } + + to { + transform: translateX(8%); + } +} + +@keyframes artifact-spotlight-breathe { + 0%, + 100% { + opacity: 0.72; + transform: scale(0.98); + } + + 50% { + opacity: 1; + transform: scale(1.04); + } +} + +@keyframes artifact-light-sweep { + 0%, + 100% { + opacity: 0.16; + filter: blur(1px); + } + + 48%, + 62% { + opacity: 0.66; + filter: blur(0.2px); + } +} + +@keyframes product-scanline-drift { + 0%, + 100% { + transform: translateX(-5%); + } + + 50% { + transform: translateX(5%); + } +} + +@keyframes specimen-field-drift { + 0%, + 100% { + transform: translate3d(-10px, 8px, 0) scale(1); + } + + 50% { + transform: translate3d(12px, -8px, 0) scale(1.03); + } +} + +@keyframes demo-road-subject-drive { + 0% { + transform: translate3d(0, 44px, 0) scale(0.62); + opacity: 0.72; + } + + 18% { + opacity: 1; + } + + 58% { + transform: translate3d(0, -22px, 0) scale(1.18); + opacity: 1; + } + + 78% { + transform: translate3d(0, -30px, 0) scale(1.22); + opacity: 0.98; + } + + 100% { + transform: translate3d(0, 44px, 0) scale(0.62); + opacity: 0.72; + } +} + +@keyframes demo-aircraft-subject-pass { + 0% { + transform: translate3d(-42vw, 14vh, 0) rotateZ(-12deg) scale(0.68); + opacity: 0; + } + + 14% { + opacity: 1; + } + + 48% { + transform: translate3d(0, -2vh, 0) rotateZ(8deg) scale(0.92); + opacity: 1; + } + + 82% { + opacity: 1; + } + + 100% { + transform: translate3d(44vw, -18vh, 0) rotateZ(18deg) scale(0.72); + opacity: 0; + } +} + +@keyframes demo-vessel-subject-cruise { + 0%, + 100% { + transform: translate3d(-16vw, 8px, 0) rotateZ(-0.6deg) scale(0.94); + } + + 50% { + transform: translate3d(16vw, -4px, 0) rotateZ(0.5deg) scale(0.98); + } +} + +@keyframes demo-artifact-subject-turntable { + 0%, + 100% { + transform: translate3d(-10px, 2px, 0) rotateZ(-0.8deg) scale(0.94); + filter: saturate(1.04) contrast(1.08) drop-shadow(0 34px 42px rgba(0, 0, 0, 0.48)); + } + + 50% { + transform: translate3d(12px, -8px, 0) rotateZ(0.8deg) scale(1.08); + filter: saturate(1.12) contrast(1.12) drop-shadow(0 38px 52px rgba(0, 0, 0, 0.62)); + } +} + +@keyframes demo-specimen-subject-orbit { + 0%, + 100% { + transform: translate3d(0, 0, 0) rotateZ(-1deg) scale(0.98); + } + + 50% { + transform: translate3d(0, -10px, 0) rotateZ(1deg) scale(1.06); + } +} + +@keyframes demo-product-subject-reveal { + 0%, + 100% { + transform: translate3d(-12px, 4px, 0) scale(0.96); + } + + 50% { + transform: translate3d(14px, -8px, 0) scale(1.08); + } +} + +@keyframes loading-pulse { + 0%, + 100% { + opacity: 0.36; + transform: scale(0.82); + } + + 50% { + opacity: 1; + transform: scale(1.08); + } +} + +.cinematic-hotspot { + position: absolute; + left: 56%; + top: 34%; + z-index: 6; + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 30px; + padding: 7px 10px; + border: 1px solid color-mix(in srgb, var(--label-color), white 54%); + border-radius: 8px; + color: #2c332e; + background: rgba(255, 252, 244, 0.8); + box-shadow: 0 12px 28px rgba(53, 67, 75, 0.12); + font: inherit; + font-size: 10px; + font-weight: 700; + cursor: pointer; + transform: translate3d(calc(var(--px) * 18px), calc(var(--py) * 12px), 0); + backdrop-filter: blur(10px); +} + +.cinematic-hotspot span { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--label-color); +} + +.generation-overlay { + position: absolute; + z-index: 5; + left: 50%; + bottom: 58px; + display: grid; + gap: 6px; + width: min(280px, calc(100% - 40px)); + padding: 11px 13px; + border: 1px solid rgba(70, 116, 180, 0.28); + border-radius: 8px; + color: #25384c; + background: rgba(232, 243, 252, 0.86); + box-shadow: 0 16px 34px rgba(53, 67, 75, 0.12); + transform: translateX(-50%); + backdrop-filter: blur(12px); +} + +.generation-overlay strong { + font-size: 12px; +} + +.generation-overlay span { + color: #526574; + font-size: 10px; +} + +.generation-overlay.failed { + border-color: rgba(178, 83, 83, 0.28); + color: #5d2b2b; + background: rgba(255, 239, 232, 0.9); +} + +.generation-overlay.failed span { + color: #7c5d55; +} + +.generation-overlay button { + justify-self: start; + min-height: 28px; + padding: 6px 10px; + border: 1px solid rgba(117, 75, 62, 0.22); + border-radius: 8px; + color: #4f312a; + background: rgba(255, 255, 255, 0.62); + font: inherit; + font-size: 10px; + font-weight: 700; + cursor: pointer; +} + +.generation-meter { + overflow: hidden; + height: 5px; + border-radius: 999px; + background: rgba(70, 116, 180, 0.14); +} + +.generation-meter i { + display: block; + width: 42%; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #6aa8cf, #8659bb); + animation: generation-meter 1150ms ease-in-out infinite; +} + +@keyframes generation-meter { + 0% { + transform: translateX(-110%); + } + + 100% { + transform: translateX(250%); + } +} + +.proof-badge { + position: absolute; + z-index: 4; + right: 288px; + top: 74px; + display: grid; + gap: 3px; + max-width: 205px; + padding: 8px 10px; + border: 1px solid rgba(70, 116, 180, 0.28); + border-radius: 8px; + color: #25384c; + background: rgba(232, 243, 252, 0.78); + box-shadow: 0 12px 28px rgba(53, 67, 75, 0.08); + backdrop-filter: blur(10px); +} + +.proof-badge strong { + font-size: 10px; + letter-spacing: 0; +} + +.proof-badge span { + color: #526574; + font-size: 10px; + line-height: 1.25; +} + +.capture-pulse { + position: absolute; + z-index: 5; + inset: 0; + pointer-events: none; + animation: capture-pulse 280ms ease-out; +} + +@keyframes capture-pulse { + 0% { + box-shadow: inset 0 0 0 0 rgba(255, 255, 255, 0); + background: rgba(255, 255, 255, 0); + } + + 30% { + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.85); + background: rgba(255, 255, 255, 0.42); + } + + 100% { + box-shadow: inset 0 0 0 0 rgba(255, 255, 255, 0); + background: rgba(255, 255, 255, 0); + } +} + +.premium-render-wrap { + position: absolute; + inset: 0; + z-index: 2; + display: grid; + place-items: center; + perspective: 900px; + pointer-events: auto; + cursor: grab; + touch-action: none; +} + +.premium-render-wrap:active { + cursor: grabbing; +} + +.premium-cell-render { + width: var(--render-width); + max-height: var(--render-max-height); + object-fit: contain; + filter: + drop-shadow(0 34px 42px rgba(69, 60, 38, 0.22)) + saturate(1.05) + contrast(1.02); + transform: + translate(var(--render-translate-x), var(--render-translate-y)) + rotateX(var(--render-rotate-x, 0deg)) + rotateY(var(--render-rotate-y, 0deg)) + scale(var(--render-scale, 1)); + transform-style: preserve-3d; + transition: transform 90ms ease-out; + user-select: none; + pointer-events: none; +} + +.render-hotspot { + position: absolute; + z-index: 3; + border: 0; + border-radius: 999px; + background: transparent; + pointer-events: auto; + cursor: pointer; +} + +.render-hotspot:hover { + outline: 1px solid rgba(99, 151, 84, 0.35); + background: rgba(255, 255, 255, 0.08); +} + +.cell-fallback { + position: absolute; + inset: 0; + z-index: 0; + display: grid; + place-items: center; + pointer-events: none; +} + +.plant-render-fallback img, +.upload-render-fallback img { + width: var(--render-width); + max-height: var(--render-max-height); + object-fit: contain; + filter: + drop-shadow(0 34px 42px rgba(69, 60, 38, 0.22)) + saturate(1.08) + contrast(1.03); + transform: translate(var(--render-translate-x), var(--render-translate-y)); + user-select: none; +} + +.plant-render-fallback img { + mix-blend-mode: multiply; +} + +.upload-render-fallback img { + width: var(--upload-render-width); + max-height: 82%; + border-radius: 8px; + opacity: 0.92; +} + +.fallback-cell-body { + position: relative; + width: min(370px, 64%); + aspect-ratio: 1.08; + margin-top: 8px; + border: 0; + border-radius: 47% 52% 50% 45%; + background: + radial-gradient(circle at 44% 42%, rgba(255, 255, 255, 0.85), transparent 18%), + radial-gradient(circle at 70% 26%, rgba(162, 88, 183, 0.24), transparent 12%), + radial-gradient(circle at 30% 74%, rgba(82, 138, 192, 0.22), transparent 14%), + #cfd9ea; + box-shadow: + inset 15px 17px 22px rgba(255, 255, 255, 0.58), + inset -16px -18px 26px rgba(107, 132, 158, 0.34), + 0 28px 52px rgba(91, 88, 78, 0.2); + pointer-events: auto; + transform: rotate(-5deg); +} + +.fallback-cell-body.active { + outline: 2px solid rgba(114, 164, 191, 0.55); + outline-offset: 4px; +} + +.fallback-cell-body.plant { + width: min(350px, 60%); + aspect-ratio: 1.22; + border-radius: 26px 36px 30px 28px; + background: + radial-gradient(circle at 42% 42%, rgba(93, 129, 57, 0.35) 0 9%, transparent 10%), + radial-gradient(circle at 62% 58%, rgba(98, 151, 70, 0.28) 0 13%, transparent 14%), + linear-gradient(135deg, #d6eab0, #9fcf77); + box-shadow: + inset 0 0 0 12px rgba(111, 167, 90, 0.28), + inset 13px 14px 22px rgba(255, 255, 255, 0.48), + 0 28px 52px rgba(91, 88, 78, 0.18); +} + +.fallback-cell-body.neuron { + width: min(390px, 66%); + aspect-ratio: 1.6; + border-radius: 45% 55% 50% 45%; + background: + radial-gradient(circle at 48% 48%, #d7c7ff 0 20%, transparent 21%), + linear-gradient(22deg, transparent 0 45%, rgba(139, 92, 246, 0.48) 46% 51%, transparent 52%), + linear-gradient(-26deg, transparent 0 42%, rgba(139, 92, 246, 0.4) 43% 48%, transparent 49%), + linear-gradient(2deg, transparent 0 53%, rgba(139, 92, 246, 0.42) 54% 58%, transparent 59%); + box-shadow: none; +} + +.fallback-cell-body.epithelial { + width: min(345px, 60%); + aspect-ratio: 1.25; + border-radius: 28px; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.42) 1px, transparent 1px), + linear-gradient(rgba(255, 255, 255, 0.42) 1px, transparent 1px), + linear-gradient(135deg, #f4c5b8, #df8583); + background-size: 74px 74px, 74px 74px, auto; +} + +.fallback-cell-body.bacteria, +.fallback-cell-body.muscle { + width: min(420px, 72%); + aspect-ratio: 2.75; + border-radius: 999px; +} + +.fallback-cell-body.bacteria { + background: + radial-gradient(circle at 34% 48%, rgba(70, 112, 199, 0.34) 0 9%, transparent 10%), + repeating-linear-gradient(90deg, transparent 0 22px, rgba(255, 255, 255, 0.32) 23px 26px), + linear-gradient(135deg, #b8ead4, #68c8a3); +} + +.fallback-cell-body.animal { + background: + radial-gradient(circle at 45% 44%, rgba(80, 139, 205, 0.22), transparent 14%), + radial-gradient(circle at 61% 57%, rgba(160, 91, 183, 0.2), transparent 13%), + #b8dcf2; +} + +.fallback-cell-body.muscle { + background: + repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.42) 0 8px, transparent 9px 34px), + linear-gradient(135deg, #f0a4ad, #c84958); +} + +.fallback-cell-body.bacteria .fallback-nucleus-one, +.fallback-cell-body.bacteria .fallback-nucleus-two, +.fallback-cell-body.muscle .fallback-nucleus-one, +.fallback-cell-body.muscle .fallback-nucleus-two { + display: none; +} + +.fallback-cell-body.neuron .fallback-lysosomes, +.fallback-cell-body.bacteria .fallback-lysosomes, +.fallback-cell-body.muscle .fallback-lysosomes { + display: none; +} + +.fallback-texture { + position: absolute; + inset: 4%; + border-radius: inherit; + background: + radial-gradient(circle at 18% 31%, rgba(255, 255, 255, 0.72) 0 2.5%, transparent 3%), + radial-gradient(circle at 76% 62%, rgba(255, 255, 255, 0.72) 0 2.5%, transparent 3%), + radial-gradient(circle at 64% 18%, rgba(78, 96, 125, 0.22) 0 2.2%, transparent 2.7%), + radial-gradient(circle at 35% 65%, rgba(78, 96, 125, 0.2) 0 2.2%, transparent 2.7%); +} + +.fallback-nucleus-one, +.fallback-nucleus-two { + position: absolute; + border-radius: 48% 52% 45% 55%; + background: + radial-gradient(circle at 35% 28%, rgba(255, 255, 255, 0.22), transparent 18%), + linear-gradient(145deg, #8b4aba, #5d2f87); + box-shadow: inset -8px -9px 18px rgba(43, 22, 70, 0.28); + cursor: pointer; +} + +.fallback-nucleus-one { + top: 20%; + left: 34%; + width: 28%; + height: 31%; + transform: rotate(-15deg); +} + +.fallback-nucleus-two { + top: 42%; + left: 51%; + width: 28%; + height: 29%; + transform: rotate(18deg); +} + +.fallback-er { + position: absolute; + left: 38%; + top: 68%; + width: 28%; + height: 15%; + border-top: 6px double #d7658d; + border-bottom: 5px double #d7658d; + border-radius: 50%; + transform: rotate(-8deg); +} + +.fallback-mito { + position: absolute; + width: 14%; + height: 7%; + border-radius: 999px; + background: + repeating-linear-gradient(90deg, transparent 0 6px, rgba(255, 255, 255, 0.48) 7px 9px), + #df7046; + cursor: pointer; +} + +.fallback-mito.one { + left: 29%; + top: 57%; + transform: rotate(48deg); +} + +.fallback-mito.two { + right: 21%; + top: 42%; + transform: rotate(-32deg); +} + +.fallback-lysosomes { + position: absolute; + top: 13%; + right: -5%; + width: 22%; + height: 29%; + cursor: pointer; + background: + radial-gradient(circle at 25% 28%, #7c3b91 0 9%, transparent 10%), + radial-gradient(circle at 52% 32%, #a15bb7 0 11%, transparent 12%), + radial-gradient(circle at 70% 54%, #7c3b91 0 9%, transparent 10%), + radial-gradient(circle at 31% 70%, #8d58b8 0 10%, transparent 11%); +} + +.fallback-granule { + position: absolute; + border-radius: 50%; + cursor: pointer; + background: #8799d6; + box-shadow: inset 1px 1px 2px rgba(255, 255, 255, 0.5); +} + +.fallback-granule.tone-1 { + background: #d8dde8; +} + +.fallback-granule.tone-2 { + background: #b6c3dc; +} + +.fallback-granule.tone-3 { + background: #dab3d2; +} + +.fallback-granule.tone-4 { + background: #85bdd4; +} + +.viewer-controls { + position: absolute; + z-index: 4; + top: 12px; + right: 12px; + width: 152px; + padding: 10px; + border: 1px solid rgba(119, 104, 76, 0.16); + border-radius: 8px; + background: rgba(250, 246, 237, 0.78); + backdrop-filter: blur(12px); +} + +.viewer-controls > span { + display: block; + color: #b19b8b; + font-family: Georgia, 'Times New Roman', serif; + font-size: 10px; + font-style: italic; + text-transform: uppercase; +} + +.mode-buttons { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + margin-top: 8px; +} + +.mode-buttons button { + display: grid; + place-items: center; + height: 32px; + border: 1px solid rgba(120, 130, 137, 0.2); + border-radius: 8px; + color: #655d55; + background: rgba(255, 255, 255, 0.5); +} + +.mode-buttons button.active { + color: #2f6b8a; + border-color: #9bc5d8; + background: #e4f2f8; +} + +.toggle-row { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 8px; + margin-top: 9px; + color: #755f4d; + font-size: 10px; +} + +.toggle-row input { + position: absolute; + opacity: 0; +} + +.toggle-row i { + position: relative; + width: 25px; + height: 14px; + border-radius: 999px; + background: #cbbca8; +} + +.toggle-row i::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 10px; + height: 10px; + border-radius: 50%; + background: #fff; + transition: transform 160ms ease; +} + +.toggle-row input:checked + i { + background: #72a4bf; +} + +.toggle-row input:checked + i::after { + transform: translateX(11px); +} + +.stage-toolbar { + position: absolute; + z-index: 4; + left: 12px; + right: 12px; + bottom: 10px; + display: grid; + grid-template-columns: repeat(5, auto) 1fr repeat(2, auto); + align-items: center; + gap: 4px; +} + +.stage-toolbar.compact-tools { + grid-template-columns: repeat(3, auto) 1fr repeat(2, auto); +} + +.stage-toolbar button { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 31px; + padding: 6px 8px; + border: 1px solid rgba(112, 101, 84, 0.2); + border-radius: 8px; + color: #4e453d; + background: rgba(252, 248, 239, 0.72); + font-size: 10px; + backdrop-filter: blur(10px); +} + +.stage-toolbar button.active { + color: #2f6b8a; + border-color: #9bc5d8; + background: rgba(228, 242, 248, 0.86); +} + +.stage-toolbar button.proof-active { + color: #294f88; + border-color: rgba(70, 116, 180, 0.42); + background: rgba(220, 236, 255, 0.92); +} + +.detail-title { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 12px 8px; + color: #8c57a4; + font-size: 10px; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.detail-title > span { + display: inline-flex; + align-items: center; + gap: 7px; +} + +.detail-fav { + display: grid; + place-items: center; + width: 25px; + height: 25px; + border-radius: 8px; + color: #b66a93; +} + +.detail-fav.active { + background: rgba(182, 106, 147, 0.12); +} + +.detail-heading { + display: grid; + grid-template-columns: 38px 1fr; + align-items: center; + gap: 10px; + padding: 8px 12px 7px; +} + +.detail-heading h2 { + overflow: hidden; + margin: 0; + color: #2b2119; + font-family: Georgia, 'Times New Roman', serif; + font-size: 17px; + font-weight: 500; + line-height: 1.1; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detail-heading p { + margin: 4px 0 0; + color: #8f8172; + font-size: 10px; +} + +.cluster-icon { + position: relative; + width: 35px; + height: 35px; +} + +.cluster-icon span { + position: absolute; + width: 13px; + height: 13px; + border-radius: 50%; + background: var(--cluster); + box-shadow: inset 2px 2px 4px rgba(255, 255, 255, 0.35); +} + +.cluster-icon span:nth-child(1) { + top: 5px; + left: 7px; +} + +.cluster-icon span:nth-child(2) { + top: 12px; + left: 17px; +} + +.cluster-icon span:nth-child(3) { + top: 18px; + left: 7px; +} + +.cluster-icon span:nth-child(4) { + top: 4px; + left: 20px; + width: 10px; + height: 10px; +} + +.asset-icon { + border-radius: 12px; + background: + radial-gradient(circle at 45% 32%, color-mix(in srgb, var(--cluster), white 24%), transparent 34%), + linear-gradient(145deg, color-mix(in srgb, var(--cluster), #111 12%), color-mix(in srgb, var(--cluster), white 34%)); + box-shadow: + inset 0 1px 2px rgba(255, 255, 255, 0.42), + 0 8px 18px rgba(49, 70, 80, 0.16); +} + +.asset-icon span { + width: 10px; + height: 10px; + background: rgba(255, 255, 255, 0.72); + box-shadow: none; +} + +.detail-grid { + display: grid; + gap: 7px; + margin: 0; + padding: 6px 12px 12px; +} + +.detail-grid div { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 8px; +} + +.detail-grid dt { + color: #9d8e7a; + font-size: 10px; +} + +.detail-grid dd { + display: block; + overflow: hidden; + min-width: 0; + margin: 0; + color: #4b4137; + font-size: 10px; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mini-toggle { + position: relative; + display: inline-block; + width: 25px; + height: 14px; + border-radius: 999px; + background: #cbbca8; +} + +.mini-toggle::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 10px; + height: 10px; + border-radius: 50%; + background: white; + transition: transform 160ms ease; +} + +.mini-toggle.active { + background: #72a4bf; +} + +.mini-toggle.active::after { + transform: translateX(11px); +} + +.notes-panel p { + padding: 2px 12px 0; + color: #675c50; + font-size: 10px; + line-height: 1.55; +} + +.notes-panel blockquote { + margin: 10px 12px 0; + padding-top: 9px; + border-top: 1px dashed rgba(123, 81, 132, 0.25); + color: #7c55a2; + font-family: inherit; + font-size: 10px; + line-height: 1.4; +} + +.asset-tags-panel { + display: grid; + align-content: start; + gap: 8px; +} + +.asset-tag-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 12px; +} + +.asset-tag-list span { + min-height: 24px; + padding: 5px 8px; + border: 1px solid rgba(115, 92, 163, 0.18); + border-radius: 999px; + color: #5d4c79; + background: rgba(247, 242, 252, 0.74); + font-size: 9px; + font-weight: 720; +} + +.asset-tags-panel p { + display: flex; + align-items: flex-start; + gap: 6px; + margin: 0; + padding: 0 12px 12px; + color: #7c746b; + font-size: 9px; + line-height: 1.4; +} + +.body-map { + position: relative; + height: 105px; + margin: 0 12px 12px; +} + +.body-figure { + position: absolute; + left: 18px; + bottom: 3px; + width: 34px; + height: 82px; + border-radius: 999px 999px 12px 12px; + background: + radial-gradient(circle at 50% 14%, #eed1c0 0 12%, transparent 13%), + linear-gradient(#d8b79d, #ead3bd); + opacity: 0.72; +} + +.body-line { + position: absolute; + top: 40px; + left: 45px; + width: 84px; + border-top: 1px dashed #5ba889; + transform: rotate(-14deg); +} + +.target-cell { + position: absolute; + right: 5px; + bottom: 13px; + display: grid; + place-items: center; + width: 62px; + height: 62px; + border: 2px solid rgba(178, 57, 65, 0.42); + border-radius: 50%; + background: #d9e0ee; +} + +.target-cell span { + width: 46px; + height: 46px; + border-radius: 50%; + background: + radial-gradient(circle at 45% 42%, #7040a0 0 19%, transparent 20%), + radial-gradient(circle at 60% 58%, #7d42ad 0 17%, transparent 18%), + radial-gradient(circle at center, #b5c0dc 0 66%, transparent 68%); +} + +.bottom-deck { + grid-column: 2; + grid-row: 2; + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(230px, 0.92fr); + gap: 10px; + min-width: 0; +} + +.micro-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + padding: 0 12px 10px; +} + +.generation-mode-row { + display: grid; + grid-template-columns: 74px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 0 12px 7px; + color: #766a5f; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; +} + +.generation-mode-pills { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 4px; + min-width: 0; +} + +.generation-mode-pills button { + min-width: 0; + min-height: 24px; + padding: 5px; + overflow: hidden; + border: 1px solid rgba(112, 101, 84, 0.16); + border-radius: 8px; + color: #5f544b; + background: rgba(255, 255, 255, 0.48); + font: inherit; + font-size: 9px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.generation-mode-pills button.active { + color: #294f88; + border-color: rgba(82, 142, 170, 0.42); + background: rgba(228, 242, 248, 0.78); +} + +.micro-card, +.add-image { + display: grid; + justify-items: center; + gap: 4px; + min-width: 0; + padding: 6px 5px; + border: 1px solid rgba(119, 104, 76, 0.16); + border-radius: 8px; + background: rgba(255, 255, 255, 0.42); + font-size: 9px; +} + +.micro-card.active, +.add-image.active { + border-color: rgba(82, 142, 170, 0.42); + background: rgba(228, 242, 248, 0.72); +} + +.task-panel { + grid-column: 1 / -1; + min-height: 0; +} + +.task-list { + display: grid; + gap: 6px; + padding: 0 12px 12px; +} + +.task-actions { + padding: 0 12px 8px; +} + +.task-actions button { + width: 100%; + min-height: 28px; + border: 1px solid rgba(64, 157, 187, 0.32); + border-radius: 8px; + color: #0f6380; + background: rgba(222, 246, 250, 0.82); + font-size: 10px; +} + +.task-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 24px 24px; + align-items: center; + gap: 6px; + min-width: 0; + padding: 6px; + border: 1px solid rgba(119, 104, 76, 0.14); + border-radius: 9px; + background: rgba(255, 255, 255, 0.42); +} + +.task-row.active { + border-color: rgba(82, 142, 170, 0.44); + background: rgba(228, 242, 248, 0.68); +} + +.task-open { + display: grid; + grid-template-columns: 32px minmax(0, 1fr); + align-items: center; + gap: 7px; + min-width: 0; + padding: 0; + border: 0; + color: inherit; + background: transparent; + text-align: left; + cursor: pointer; +} + +.task-open strong, +.task-open small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-open strong { + color: #39312c; + font-size: 10px; +} + +.task-open small { + color: #81776b; + font-size: 9px; +} + +.task-state, +.task-retry { + display: inline-grid; + place-items: center; + width: 24px; + height: 24px; + border: 1px solid rgba(119, 104, 76, 0.14); + border-radius: 8px; + color: #81776b; + background: rgba(255, 255, 255, 0.48); +} + +.task-state.active { + color: #2f6b8a; + background: rgba(228, 242, 248, 0.78); +} + +.task-state.done { + color: #42794f; + background: rgba(231, 243, 225, 0.78); +} + +.task-state.failed { + color: #9c4e4f; + background: rgba(252, 233, 231, 0.82); +} + +.task-retry { + cursor: pointer; +} + +.task-empty { + display: flex; + align-items: center; + gap: 8px; + min-height: 54px; + padding: 0 12px 12px; + color: #81776b; + font-size: 10px; +} + +.task-history { + display: grid; + gap: 5px; + padding: 0 12px 12px; +} + +.task-history > strong { + color: #6555a3; + font-size: 9px; + text-transform: uppercase; +} + +.task-history-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 7px; + align-items: center; + min-height: 28px; + padding: 5px 7px; + border: 1px solid rgba(119, 104, 76, 0.12); + border-radius: 8px; + color: #40515b; + background: rgba(255, 255, 255, 0.42); + font-size: 9px; + text-align: left; +} + +.task-history-row span, +.task-history-row small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.micro-card span { + width: 100%; + height: 36px; + border-radius: 6px; + background: + radial-gradient(circle at 24% 40%, #7b4bb4 0 8%, transparent 9%), + radial-gradient(circle at 62% 35%, #d1879a 0 10%, transparent 11%), + radial-gradient(circle at 72% 68%, #b876a1 0 9%, transparent 10%), + radial-gradient(circle at center, #e4c8c9, #c8a4a7); +} + +.micro-card.purple span { + background: + radial-gradient(circle at 32% 38%, #5a2f7d 0 12%, transparent 13%), + radial-gradient(circle at 70% 50%, #7f489e 0 10%, transparent 11%), + linear-gradient(135deg, #d7c0df, #8e6ca8); +} + +.micro-card.mono span { + filter: grayscale(1); + background: + radial-gradient(circle at 34% 35%, #3f3f3f 0 10%, transparent 11%), + radial-gradient(circle at 68% 64%, #777 0 10%, transparent 11%), + linear-gradient(135deg, #c6c6c6, #494949); +} + +.add-image { + align-content: center; + color: #7a6b5d; + border-style: dashed; +} + +.add-image.with-preview { + position: relative; + overflow: hidden; + color: #3f3933; +} + +.add-image.with-preview::before { + content: ''; + width: 100%; + height: 36px; + border-radius: 6px; + background: var(--upload-preview) center / cover; +} + +.add-image.with-preview svg { + display: none; +} + +.add-image.with-model { + color: #315370; + border-style: solid; +} + +.add-image.with-model svg { + width: 28px; + height: 28px; + padding: 5px; + border-radius: 8px; + color: #2f6f9d; + background: rgba(207, 229, 240, 0.74); +} + +.hidden-file-input { + display: none; +} + +.compare-box { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) 30px 34px minmax(0, 1fr); + align-items: center; + gap: 8px; + margin: 0 12px 12px; + padding: 8px; + border: 1px solid rgba(119, 104, 76, 0.14); + border-radius: 8px; + background: rgba(255, 255, 255, 0.36); + text-align: left; +} + +.compare-box > div { + min-width: 0; +} + +.compare-box strong, +.compare-box small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compare-box:hover { + border-color: rgba(112, 82, 173, 0.24); + background: rgba(255, 255, 255, 0.52); +} + +.versus { + display: grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 50%; + color: white; + background: #7052ad; + font-size: 10px; + font-weight: 700; +} + +.studio-shell { + color: #1d2b34; + background: + radial-gradient(circle at 12% 8%, rgba(89, 197, 217, 0.22), transparent 26%), + radial-gradient(circle at 86% 14%, rgba(143, 104, 214, 0.16), transparent 24%), + radial-gradient(circle at 42% 92%, rgba(244, 142, 101, 0.12), transparent 24%), + linear-gradient(180deg, #f8fbfc 0%, #edf5f6 100%); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.studio-window { + border-color: rgba(86, 130, 150, 0.3); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(244, 249, 248, 0.78)), + rgba(246, 250, 249, 0.9); + box-shadow: + 0 30px 86px rgba(38, 65, 75, 0.18), + 0 8px 26px rgba(80, 115, 126, 0.08), + inset 0 0 0 1px rgba(255, 255, 255, 0.72); + backdrop-filter: blur(18px); +} + +.studio-header { + border-bottom-color: rgba(86, 130, 150, 0.16); + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.84), rgba(236, 247, 248, 0.72)), + rgba(255, 255, 255, 0.68); +} + +.brand-mark { + border: 1px solid rgba(92, 137, 160, 0.18); + background: + radial-gradient(circle at 30% 24%, rgba(255, 255, 255, 0.98), transparent 36%), + rgba(255, 255, 255, 0.68); + box-shadow: 0 10px 24px rgba(55, 95, 110, 0.1); +} + +.studio-brand strong, +.stage-title h1, +.workspace-drawer strong, +.detail-heading h2 { + color: #101820; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-weight: 760; + letter-spacing: 0; +} + +.studio-brand strong { + font-size: 23px; +} + +.studio-brand span, +.stage-title p, +.viewer-controls > span, +.notes-panel blockquote { + color: #6f55b5; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-style: normal; + font-weight: 560; +} + +.studio-brand span { + color: #6b6fd6; + font-size: 11px; +} + +.studio-nav button { + color: #4a5a63; +} + +.studio-nav button:hover, +.profile-button:hover, +.stage-toolbar button:hover, +.viewer-controls button:hover, +.drawer-primary:hover, +.drawer-secondary:hover, +.generation-mode-pills button:hover, +.micro-card:hover, +.add-image:hover, +.compare-box:hover, +.cell-row:hover, +.organelle-row:hover { + filter: none; + border-color: rgba(74, 157, 184, 0.32); + background: rgba(232, 247, 250, 0.72); +} + +.studio-nav button.active, +.profile-button.active, +.viewer-controls button.active, +.stage-toolbar button.active, +.generation-mode-pills button.active, +.micro-card.active, +.add-image.active, +.library-card.active, +.cell-chip-grid button.active, +.segmented button.active { + color: #0f6380; + border-color: rgba(64, 157, 187, 0.42); + background: + linear-gradient(180deg, rgba(236, 250, 252, 0.94), rgba(215, 240, 247, 0.82)); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.78); +} + +.profile-button, +.drawer-primary, +.drawer-secondary, +.cell-chip-grid button, +.segmented button, +.generation-mode-pills button, +.stage-toolbar button, +.mode-buttons button { + border-color: rgba(93, 119, 130, 0.18); + color: #40515b; + background: rgba(255, 255, 255, 0.68); + box-shadow: 0 6px 16px rgba(44, 78, 88, 0.05); +} + +.workspace-drawer, +.panel, +.viewer-controls, +.stage-label, +.proof-launcher, +.proof-badge, +.custom-reference-layer, +.generation-overlay, +.status-toast { + border-color: rgba(86, 130, 150, 0.18); + background: rgba(255, 255, 255, 0.66); + box-shadow: + 0 16px 36px rgba(42, 70, 82, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.7); + backdrop-filter: blur(16px); +} + +.panel { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(246, 250, 247, 0.62)), + rgba(255, 255, 255, 0.56); +} + +.panel-title, +.detail-title { + color: #6555a3; + font-size: 10px; + letter-spacing: 0.02em; +} + +.cell-row, +.organelle-row { + color: #46535b; +} + +.cell-row.active { + border-color: rgba(70, 157, 185, 0.34); + color: #104e67; + background: + linear-gradient(90deg, rgba(225, 248, 252, 0.94), rgba(239, 251, 247, 0.76)); + box-shadow: inset 3px 0 0 #48a9c5; +} + +.organelle-row.active { + color: #243846; + background: rgba(220, 234, 229, 0.8); +} + +.cell-row strong, +.compare-box strong, +.gallery-hero strong, +.uploaded-tile strong, +.drawer-row strong, +.compare-card strong, +.reference-section > strong, +.reference-card strong, +.library-card strong, +.settings-row strong { + color: #1c2830; +} + +.cell-row small, +.compare-box small, +.gallery-hero > div > span, +.uploaded-tile small, +.drawer-row > span:not(.cell-thumb) small, +.compare-card small, +.reference-card small, +.library-card small, +.settings-row small { + color: #697983; +} + +.cell-thumb { + border-color: rgba(95, 133, 151, 0.2); + background: rgba(255, 255, 255, 0.76); + box-shadow: 0 8px 18px rgba(43, 78, 92, 0.08); +} + +.stage-panel { + border-color: rgba(81, 132, 150, 0.2); + background: + radial-gradient(ellipse at 58% 53%, rgba(255, 255, 255, 0.96) 0 18%, rgba(236, 249, 247, 0.68) 34%, transparent 58%), + radial-gradient(circle at 88% 16%, rgba(126, 94, 210, 0.12), transparent 24%), + linear-gradient(140deg, rgba(255, 255, 255, 0.94), rgba(232, 243, 244, 0.82) 58%, rgba(248, 244, 236, 0.9)), + #eef6f5; +} + +.stage-panel::before { + background: radial-gradient(ellipse at center, rgba(33, 56, 65, 0.16), rgba(72, 119, 116, 0.06) 44%, transparent 72%); +} + +.stage-panel::after { + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.45), transparent 26%, transparent 74%, rgba(255, 255, 255, 0.32)), + radial-gradient(circle at 88% 16%, rgba(74, 157, 184, 0.15), transparent 20%); +} + +.stage-title h1 { + font-size: 30px; +} + +.stage-title p { + color: #7a8587; + font-size: 13px; +} + +.stage-status { + color: #718087; +} + +.stage-label { + color: #1f3039; + background: rgba(255, 255, 255, 0.76); +} + +.proof-launcher, +.proof-launcher.active, +.stage-toolbar button.proof-active { + color: #14547d; + border-color: rgba(63, 143, 190, 0.4); + background: + linear-gradient(180deg, rgba(225, 244, 255, 0.96), rgba(206, 232, 250, 0.88)); +} + +.detail-title { + color: #7a4fac; +} + +.detail-heading p, +.detail-grid dt, +.notes-panel p, +.body-map, +.generation-mode-row, +.reference-section > span, +.drawer-copy, +.drawer-meta span { + color: #66747b; +} + +.detail-grid dd { + color: #26353d; +} + +.notes-panel blockquote { + color: #7656b9; + border-top-color: rgba(111, 85, 181, 0.22); +} + +.body-figure { + background: + radial-gradient(circle at 50% 14%, #f3d8c6 0 12%, transparent 13%), + linear-gradient(#d2e4de, #f0d4bf); +} + +.body-line { + border-top-color: #50ad94; +} + +.target-cell { + border-color: rgba(106, 95, 201, 0.42); + background: #dce9f4; +} + +.gallery-hero, +.uploaded-tile, +.drawer-row, +.compare-card, +.settings-row, +.reference-section, +.reference-card, +.library-card, +.profile-stats span, +.drawer-meta span, +.compare-box, +.micro-card, +.add-image, +.note-editor textarea, +.empty-state { + border-color: rgba(92, 124, 139, 0.16); + background: rgba(255, 255, 255, 0.58); +} + +.drawer-primary { + color: #0f6380; + border-color: rgba(64, 157, 187, 0.36); + background: rgba(222, 246, 250, 0.82); +} + +.micro-card span { + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.38); +} + +.versus { + background: linear-gradient(145deg, #6957c9, #38a6bd); + box-shadow: 0 8px 18px rgba(65, 94, 151, 0.18); +} + +.mini-toggle.active, +.toggle-row input:checked + i { + background: #4aa5bd; +} + +.status-toast { + color: #30444c; + background: rgba(244, 251, 250, 0.92); +} + +.status-toast span { + background: #4dbb91; + box-shadow: 0 0 0 4px rgba(77, 187, 145, 0.14); +} + +button:hover { + filter: brightness(0.985); +} + +button:focus-visible, +input:focus-visible + i { + outline: 2px solid #79b4d0; + outline-offset: 2px; +} + +.compact-ui .studio-grid { + gap: 8px; + padding: 8px 10px 10px; +} + +.compact-ui .panel-title, +.compact-ui .detail-title { + padding-top: 9px; +} + +.compact-ui .cell-row { + min-height: 40px; +} + +@media (min-width: 1280px) and (min-height: 720px) { + .studio-shell { + padding: 24px; + } + + .studio-window { + width: min(1360px, calc(100vw - 48px)); + height: min(820px, calc(100vh - 48px)); + } + + .stage-panel { + --render-width: min(840px, 92%); + --render-max-height: 108%; + --render-translate-x: 18px; + --render-translate-y: 18px; + --upload-render-width: min(640px, 76%); + } + + .studio-grid { + grid-template-columns: 196px minmax(520px, 1fr) 232px; + grid-template-rows: minmax(440px, 1fr) 146px; + } + + .left-rail { + grid-template-rows: minmax(440px, 1fr) 146px; + } + + .right-rail { + grid-template-rows: 196px 168px minmax(80px, 1fr); + } +} + +@media (min-width: 1600px) and (min-height: 860px) { + .studio-shell { + padding: 32px; + } + + .studio-window { + width: min(1560px, calc(100vw - 64px)); + height: min(920px, calc(100vh - 64px)); + } + + .stage-panel { + --render-width: min(980px, 90%); + --render-max-height: 110%; + --render-translate-x: 22px; + --render-translate-y: 22px; + --upload-render-width: min(700px, 72%); + } + + .studio-grid { + grid-template-columns: 224px minmax(640px, 1fr) 264px; + grid-template-rows: minmax(520px, 1fr) 158px; + gap: 12px 14px; + } + + .left-rail { + grid-template-rows: minmax(520px, 1fr) 158px; + } + + .right-rail { + grid-template-rows: 216px 188px minmax(88px, 1fr); + } + + .stage-title h1 { + font-size: 36px; + } + + .stage-title p { + font-size: 16px; + } +} + +@media (min-width: 1920px) and (min-height: 1000px) { + .studio-window { + width: min(1760px, calc(100vw - 96px)); + height: min(1040px, calc(100vh - 96px)); + } + + .stage-panel { + --render-width: min(1060px, 88%); + --render-max-height: 112%; + --render-translate-x: 28px; + --render-translate-y: 24px; + --upload-render-width: min(760px, 70%); + } + + .studio-grid { + grid-template-columns: 256px minmax(760px, 1fr) 296px; + grid-template-rows: minmax(620px, 1fr) 172px; + gap: 14px 16px; + padding: 14px 16px 16px; + } + + .left-rail { + grid-template-rows: minmax(620px, 1fr) 172px; + } + + .right-rail { + grid-template-rows: 232px 204px minmax(96px, 1fr); + } +} + +@media (max-width: 980px) { + .studio-shell { + padding: 0; + } + + .studio-window { + width: 100vw; + height: auto; + min-height: 100vh; + border: 0; + border-radius: 0; + overflow: visible; + } + + .studio-header { + grid-template-columns: 1fr auto; + } + + .studio-nav { + display: none; + } + + .studio-grid { + grid-template-columns: 1fr; + grid-template-rows: auto; + height: auto; + } + + .left-rail, + .right-rail, + .stage-panel, + .bottom-deck { + grid-column: auto; + grid-row: auto; + } + + .left-rail, + .right-rail, + .bottom-deck { + grid-template-columns: 1fr; + } + + .stage-panel { + height: 420px; + } + + .bottom-deck { + grid-template-columns: 1fr; + } +} + +@media (max-width: 560px) { + .studio-brand strong { + font-size: 19px; + } + + .studio-brand span { + display: none; + } + + .stage-title h1 { + font-size: 27px; + } + + .viewer-controls { + width: 132px; + } + + .stage-toolbar { + grid-template-columns: repeat(2, 1fr); + } + + .stage-toolbar span { + display: none; + } + + .generation-mode-row { + grid-template-columns: 1fr; + } + + .generation-mode-pills { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (min-width: 981px) { + .studio-window { + --side-dock-width: 204px; + --inspector-width: 268px; + --control-dock-height: 142px; + --workspace-gap: 12px; + } + + .studio-grid { + position: relative; + grid-template-columns: var(--side-dock-width) minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + gap: var(--workspace-gap); + height: calc(100% - var(--header-height)); + padding: 12px; + } + + .left-rail { + z-index: 6; + grid-column: 1; + grid-row: 1; + grid-template-rows: minmax(0, 1fr) minmax(132px, 0.34fr); + } + + .stage-panel { + z-index: 1; + grid-column: 2; + grid-row: 1; + min-height: 0; + --render-width: min(920px, 92%); + --render-max-height: 112%; + --render-translate-x: 30px; + --render-translate-y: 22px; + } + + .right-rail { + position: absolute; + z-index: 8; + top: 24px; + right: 24px; + bottom: calc(var(--control-dock-height) + 28px); + width: var(--inspector-width); + grid-column: auto; + grid-row: auto; + grid-template-rows: minmax(132px, 0.88fr) minmax(134px, 1fr) minmax(104px, 0.78fr); + } + + .right-rail .panel { + background: rgba(255, 255, 255, 0.72); + box-shadow: + 0 20px 45px rgba(34, 68, 82, 0.12), + inset 0 1px 0 rgba(255, 255, 255, 0.8); + } + + .bottom-deck { + position: absolute; + z-index: 9; + left: calc(var(--side-dock-width) + (var(--workspace-gap) * 2)); + right: calc(var(--inspector-width) + 36px); + bottom: 14px; + grid-column: auto; + grid-row: auto; + grid-template-columns: minmax(360px, 1.08fr) minmax(240px, 0.72fr); + height: var(--control-dock-height); + } + + .bottom-deck .panel { + background: rgba(255, 255, 255, 0.72); + box-shadow: + 0 18px 44px rgba(36, 69, 82, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.86); + } + + .viewer-controls { + top: 18px; + right: calc(var(--inspector-width) + 44px); + } + + .proof-launcher { + top: 82px; + right: calc(var(--inspector-width) + 202px); + } + + .proof-badge { + top: 82px; + right: calc(var(--inspector-width) + 320px); + } + + .stage-label { + top: 86px; + } + + .proof-launcher + .proof-badge + .stage-label, + .proof-launcher.active + .proof-badge + .stage-label { + top: 124px; + } + + .stage-toolbar { + left: 18px; + right: calc(var(--inspector-width) + 34px); + bottom: calc(var(--control-dock-height) + 28px); + } +} + +@media (min-width: 981px) and (max-width: 1279px) { + .studio-window { + --side-dock-width: 176px; + --inspector-width: 206px; + --control-dock-height: 132px; + --workspace-gap: 10px; + } + + .studio-grid { + grid-template-columns: 176px minmax(420px, 1fr) 206px; + grid-template-rows: minmax(300px, 1fr) 132px; + padding: 10px; + } + + .left-rail { + grid-column: 1; + grid-row: 1 / 3; + grid-template-rows: minmax(300px, 1fr) 132px; + } + + .stage-panel { + grid-column: 2; + grid-row: 1; + --render-width: min(650px, 92%); + --render-max-height: 106%; + --render-translate-x: 16px; + --render-translate-y: 10px; + } + + .right-rail { + position: static; + grid-column: 3; + grid-row: 1 / 3; + width: auto; + grid-template-rows: 168px 170px minmax(64px, 1fr); + } + + .bottom-deck { + position: static; + grid-column: 2; + grid-row: 2; + grid-template-columns: minmax(0, 1fr) minmax(200px, 0.76fr); + height: auto; + } + + .viewer-controls { + top: 12px; + right: 12px; + } + + .proof-launcher { + top: 74px; + right: 170px; + } + + .proof-badge { + top: 74px; + right: 288px; + } + + .stage-toolbar { + left: 12px; + right: 12px; + bottom: 10px; + } +} + +@media (min-width: 1600px) { + .studio-window { + --side-dock-width: 240px; + --inspector-width: 292px; + --control-dock-height: 158px; + } + + .stage-panel { + --render-width: min(1080px, 92%); + } + + .bottom-deck { + grid-template-columns: minmax(560px, 1.06fr) minmax(320px, 0.72fr); + } +} + +@media (min-width: 1920px) { + .studio-window { + --side-dock-width: 268px; + --inspector-width: 316px; + --control-dock-height: 172px; + } + + .stage-panel { + --render-width: min(1180px, 91%); + } +} + +/* Workbench v3: left navigation, center stage, right tool rail. */ +.workbench-v2 { + --header-height: 58px; + --side-width: 276px; + --tool-width: 300px; + --inspector-width: 334px; + --workbench-gap: 14px; +} + +.studio-workbench-v2 { + position: relative; + height: calc(100% - var(--header-height)); + overflow: hidden; + background: + radial-gradient(circle at 20% 20%, rgba(78, 180, 205, 0.12), transparent 24%), + radial-gradient(circle at 74% 18%, rgba(130, 99, 211, 0.1), transparent 20%), + linear-gradient(135deg, rgba(245, 251, 252, 0.96), rgba(235, 244, 244, 0.88)); +} + +.stage-zone, +.selection-shelf, +.inspector-zone, +.command-zone { + position: absolute; + min-width: 0; +} + +.stage-zone { + z-index: 1; + top: var(--workbench-gap); + right: calc(var(--tool-width) + (var(--workbench-gap) * 2)); + bottom: var(--workbench-gap); + left: calc(var(--side-width) + (var(--workbench-gap) * 2)); +} + +.stage-zone .stage-panel { + position: absolute; + inset: 0; + width: auto; + height: auto; + min-height: 0; + border-color: rgba(79, 129, 151, 0.22); + --render-width: min(980px, 94%); + --render-max-height: 112%; + --render-translate-x: 30px; + --render-translate-y: 24px; + --upload-render-width: min(620px, 74%); +} + +.selection-shelf { + z-index: 12; + top: var(--workbench-gap); + left: var(--workbench-gap); + bottom: var(--workbench-gap); + width: var(--side-width); +} + +.selection-shelf .left-rail { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: minmax(0, 1fr) minmax(168px, 0.34fr); + gap: 10px; + height: 100%; +} + +.selection-shelf .panel { + min-height: 0; + background: rgba(255, 255, 255, 0.74); + overflow: hidden; +} + +.selection-shelf .cell-types-panel { + display: flex; + flex-direction: column; +} + +.selection-shelf .panel-title { + flex: 0 0 auto; + min-height: 36px; + padding: 10px 12px 6px; +} + +.selection-shelf .cell-list { + display: grid; + align-content: start; + grid-template-columns: 1fr; + gap: 8px; + flex: 1 1 auto; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + padding: 10px 12px 12px; +} + +.pinned-models { + display: grid; + gap: 8px; + flex: 0 0 auto; + padding: 4px 12px 12px; + border-bottom: 1px solid rgba(102, 91, 73, 0.11); +} + +.pinned-model-block { + display: grid; + gap: 6px; +} + +.model-section-label { + display: block; + color: #746a5d; + font-size: 9px; + font-weight: 820; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.selection-shelf .cell-row { + width: 100%; + min-height: 64px; + grid-template-columns: 38px minmax(0, 1fr) auto; + padding: 9px 10px; +} + +.selection-shelf .cell-row > span { + min-width: 0; +} + +.selection-shelf .cell-row-shell { + position: relative; + min-width: 0; +} + +.selection-shelf .cell-row-shell.can-delete .cell-row { + padding-right: 36px; +} + +.selection-shelf .cell-row.compact { + min-height: 52px; +} + +.selection-shelf .cell-delete { + position: absolute; + top: 50%; + right: 8px; + display: grid; + place-items: center; + width: 25px; + height: 25px; + border-radius: 8px; + color: #8a6270; + background: rgba(255, 255, 255, 0.64); + transform: translateY(-50%); +} + +.selection-shelf .cell-delete:hover { + color: #b5455e; + background: rgba(255, 235, 240, 0.92); +} + +.recent-cells { + display: grid; + gap: 7px; + padding-top: 2px; +} + +.recent-toggle { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 8px; + min-height: 32px; + padding: 7px 9px; + border: 1px dashed rgba(111, 92, 137, 0.24); + border-radius: 9px; + color: #6f5c89; + background: rgba(255, 255, 255, 0.45); + font-size: 10px; + font-weight: 780; + text-align: left; +} + +.recent-toggle small { + display: inline-grid; + place-items: center; + min-width: 20px; + height: 20px; + border-radius: 999px; + color: #2f6b8a; + background: rgba(222, 241, 248, 0.88); +} + +.recent-cell-list { + display: grid; + gap: 6px; +} + +.starter-cells { + display: grid; + gap: 7px; + padding-top: 2px; +} + +.starter-cell-list { + display: grid; + gap: 6px; +} + +.selection-shelf .cell-row strong { + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + white-space: normal; +} + +.selection-shelf .cell-row small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.selection-shelf .organelle-list { + display: grid; + grid-template-columns: 1fr; + gap: 6px; + overflow: auto; + padding: 0 12px 12px; +} + +.selection-shelf .organelle-row { + min-height: 32px; + padding: 7px 9px; +} + +.left-queue-list { + display: grid; + align-content: start; + gap: 7px; + overflow: auto; + padding: 0 12px 12px; +} + +.left-queue-row { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 6px; + min-width: 0; + border: 1px solid rgba(112, 101, 84, 0.12); + border-radius: 9px; + background: rgba(255, 255, 255, 0.54); +} + +.left-queue-row.active { + border-color: rgba(82, 142, 170, 0.38); + background: rgba(222, 241, 248, 0.76); +} + +.left-queue-row > button:first-child { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + align-items: center; + gap: 8px; + min-width: 0; + padding: 8px 8px; + border: 0; + background: transparent; + text-align: left; +} + +.left-queue-row > button:first-child > span { + min-width: 0; +} + +.left-queue-row strong { + display: block; + overflow: hidden; + color: #2d261f; + font-size: 11px; + font-weight: 760; + text-overflow: ellipsis; + white-space: nowrap; +} + +.left-queue-row small { + display: block; + overflow: hidden; + margin-top: 3px; + color: #817568; + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.queue-state { + display: grid; + place-items: center; + width: 24px; + height: 24px; + margin-right: 8px; + border-radius: 8px; + color: #6d6258; + background: rgba(250, 246, 235, 0.74); +} + +.queue-state.ready { + color: #40795d; + background: rgba(223, 246, 231, 0.86); +} + +.queue-state.failed { + color: #9d4c54; + background: rgba(255, 229, 232, 0.86); +} + +.queue-state.active { + color: #2f7190; + background: rgba(222, 241, 248, 0.86); +} + +.queue-retry { + position: absolute; + right: 7px; + bottom: 6px; + display: grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: 7px; + color: #8a6270; + background: rgba(255, 255, 255, 0.78); +} + +.queue-empty { + display: grid; + align-self: start; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 8px; + margin: 0 12px 12px; + padding: 10px; + border: 1px dashed rgba(111, 92, 137, 0.22); + border-radius: 9px; + color: #766a5f; + background: rgba(255, 255, 255, 0.48); + font-size: 10px; +} + +.library-empty { + display: grid; + align-self: start; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 4px 8px; + padding: 12px 10px; + border: 1px dashed rgba(111, 92, 137, 0.22); + border-radius: 9px; + color: #766a5f; + background: rgba(255, 255, 255, 0.48); + font-size: 10px; +} + +.library-empty.compact-empty { + margin-top: 2px; +} + +.library-empty svg { + grid-row: span 2; + color: #7d66b2; +} + +.library-empty span { + color: #2f3d49; + font-weight: 780; +} + +.library-empty small { + overflow-wrap: anywhere; + color: #817568; +} + +.model-detail-list { + display: grid; + gap: 8px; + overflow: auto; + padding: 0 12px 12px; +} + +.model-detail-row { + display: grid; + gap: 4px; + padding: 9px 10px; + border: 1px solid rgba(112, 101, 84, 0.12); + border-radius: 8px; + background: rgba(255, 255, 255, 0.52); +} + +.model-detail-row span { + color: #817568; + font-size: 9px; + font-weight: 760; + text-transform: uppercase; +} + +.model-detail-row strong { + overflow: hidden; + color: #2f3d49; + font-size: 11px; + font-weight: 760; + text-overflow: ellipsis; + white-space: nowrap; +} + +.model-detail-note { + margin: 0; + padding: 9px 10px; + border-radius: 8px; + color: #6b5f55; + background: rgba(250, 246, 235, 0.66); + font-size: 10px; + line-height: 1.45; +} + +.inspector-zone { + z-index: 32; + top: 14px; + right: 14px; + bottom: 14px; + width: var(--inspector-width); + max-width: calc(100% - 28px); + padding-top: 46px; + pointer-events: none; + opacity: 0; + visibility: hidden; + transform: translateX(calc(100% + 24px)); + transition: + transform 220ms ease, + opacity 180ms ease, + visibility 180ms ease; +} + +.inspector-zone.open { + pointer-events: auto; + opacity: 1; + visibility: visible; + transform: translateX(0); +} + +.inspector-zone .right-rail { + position: static; + display: grid; + width: auto; + height: 100%; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 10px; +} + +.inspector-zone .panel { + background: rgba(255, 255, 255, 0.76); + box-shadow: + 0 22px 54px rgba(38, 70, 82, 0.14), + inset 0 1px 0 rgba(255, 255, 255, 0.82); +} + +.inspector-trigger { + position: absolute; + z-index: 16; + top: 142px; + right: calc(var(--tool-width) + (var(--workbench-gap) * 2) + 20px); + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 38px; + padding: 0 13px; + border: 1px solid rgba(108, 176, 204, 0.45); + border-radius: 14px; + color: #275f83; + background: rgba(255, 255, 255, 0.74); + box-shadow: 0 12px 32px rgba(41, 86, 105, 0.14); + font-weight: 780; + backdrop-filter: blur(18px); +} + +.inspector-trigger:hover, +.inspector-trigger.active { + background: rgba(225, 246, 252, 0.92); + border-color: rgba(70, 164, 202, 0.72); + color: #1f658d; +} + +.inspector-scrim { + position: absolute; + z-index: 28; + inset: 0; + border: 0; + background: transparent; +} + +.inspector-close { + position: absolute; + top: 0; + right: 0; + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 36px; + padding: 0 12px; + border: 1px solid rgba(115, 92, 163, 0.18); + border-radius: 13px; + color: #5f5278; + background: rgba(255, 255, 255, 0.88); + box-shadow: 0 12px 28px rgba(39, 58, 77, 0.12); + font-weight: 720; +} + +.command-zone { + z-index: 14; + top: var(--workbench-gap); + right: var(--workbench-gap); + bottom: var(--workbench-gap); + width: var(--tool-width); +} + +.command-zone .bottom-deck { + position: static; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: auto auto minmax(0, 1fr); + align-content: start; + gap: 10px; + width: 100%; + height: 100%; + overflow: auto; + scrollbar-width: thin; +} + +.command-zone .panel { + background: rgba(255, 255, 255, 0.76); + box-shadow: + 0 20px 50px rgba(38, 70, 82, 0.12), + inset 0 1px 0 rgba(255, 255, 255, 0.84); +} + +.command-zone .generation-mode-row { + grid-template-columns: 1fr; + gap: 8px; +} + +.command-zone .generation-mode-pills { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.command-zone .task-panel { + grid-column: auto; + min-height: 150px; +} + +.command-zone .task-list { + max-height: 240px; + overflow: auto; +} + +.command-zone .micro-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-content: start; +} + +.command-zone .compare-box { + max-width: none; + grid-template-columns: 34px minmax(0, 1fr) 34px 34px minmax(0, 1fr); + padding: 10px; +} + +.stage-zone .stage-title { + top: 28px; + left: 28px; +} + +.stage-zone .stage-title h1 { + font-size: 34px; +} + +.stage-zone .stage-status { + top: 106px; + left: 30px; +} + +.stage-zone .model-quality-card { + top: 136px; + left: 30px; +} + +.stage-zone .viewer-controls { + top: 22px; + right: 28px; +} + +.stage-zone .stage-toolbar { + left: 28px; + right: 28px; + bottom: 20px; +} + +.stage-zone .custom-reference-layer { + top: 252px; + left: 30px; +} + +.workbench-v2 .status-toast { + right: 26px; + bottom: 26px; +} + +.demo-exit-button { + position: absolute; + z-index: 48; + top: 16px; + right: 16px; + min-height: 38px; + padding: 0 14px; + border: 1px solid rgba(108, 176, 204, 0.46); + border-radius: 999px; + color: #275f83; + background: rgba(255, 255, 255, 0.78); + box-shadow: 0 16px 42px rgba(32, 75, 92, 0.18); + font-weight: 800; + backdrop-filter: blur(18px); +} + +.workbench-v2.demo-mode { + --header-height: 0px; + --side-width: 0px; + --tool-width: 0px; +} + +.workbench-v2.demo-mode .studio-header { + height: 0; + min-height: 0; + padding: 0; + overflow: hidden; + border: 0; +} + +.workbench-v2.demo-mode .selection-shelf, +.workbench-v2.demo-mode .command-zone, +.workbench-v2.demo-mode .workspace-drawer, +.workbench-v2.demo-mode .inspector-trigger, +.workbench-v2.demo-mode .stage-title, +.workbench-v2.demo-mode .stage-status, +.workbench-v2.demo-mode .viewer-controls, +.workbench-v2.demo-mode .model-quality-card, +.workbench-v2.demo-mode .stage-toolbar, +.workbench-v2.demo-mode .status-toast, +.workbench-v2.demo-mode .custom-reference-layer { + display: none; +} + +.workbench-v2.demo-mode .stage-zone { + top: var(--workbench-gap); + right: var(--workbench-gap); + bottom: var(--workbench-gap); + left: var(--workbench-gap); +} + +.workbench-v2.demo-mode .stage-panel { + --render-width: min(1320px, 94%); + --render-max-height: 114%; + --render-translate-x: 26px; + --render-translate-y: 18px; +} + +.workbench-v2.demo-mode .stage-panel.motion-road { + background: + radial-gradient(ellipse at 50% 46%, rgba(255, 255, 255, 0.9) 0 12%, transparent 44%), + linear-gradient(180deg, rgba(224, 241, 246, 0.88) 0 38%, rgba(155, 172, 174, 0.9) 38% 100%), + #d9e7e8; +} + +.workbench-v2.demo-mode .stage-panel.motion-aircraft { + background: + radial-gradient(circle at 58% 26%, rgba(255, 255, 255, 0.86), transparent 24%), + linear-gradient(145deg, rgba(224, 244, 252, 0.92), rgba(245, 250, 255, 0.72) 52%, rgba(220, 232, 248, 0.86)), + #eef7fb; +} + +.workbench-v2.demo-mode .stage-panel.motion-vessel { + background: + radial-gradient(circle at 50% 26%, rgba(255, 255, 255, 0.72), transparent 24%), + linear-gradient(180deg, rgba(225, 242, 247, 0.96) 0 42%, rgba(91, 155, 174, 0.92) 42% 100%), + #cfeaf0; +} + +.workbench-v2.demo-mode .stage-panel.motion-artifact { + background: + radial-gradient(circle at 50% 32%, rgba(255, 198, 105, 0.24), transparent 20%), + radial-gradient(ellipse at 50% 74%, rgba(176, 115, 40, 0.18), transparent 36%), + linear-gradient(180deg, #0b0a09, #12100e 52%, #090807); +} + +.workbench-v2.demo-mode .stage-panel.motion-artifact::before { + background: radial-gradient(ellipse at center, rgba(205, 148, 61, 0.38), rgba(205, 148, 61, 0.08) 44%, transparent 72%); +} + +.workbench-v2.demo-mode .stage-panel.motion-artifact .demo-showcase-title, +.workbench-v2.demo-mode .stage-panel.motion-artifact .demo-showcase-title small, +.workbench-v2.demo-mode .stage-panel.motion-artifact .demo-showcase-title p { + color: #f6e6c8; + text-shadow: 0 1px 12px rgba(0, 0, 0, 0.66); +} + +.workbench-v2.demo-mode .stage-panel.motion-artifact .demo-showcase-title span { + color: #d8a74f; +} + +.workbench-v2.demo-mode .stage-panel.motion-artifact .demo-scene-badges em, +.workbench-v2.demo-mode .stage-panel.motion-artifact .demo-metric-strip span, +.workbench-v2.demo-mode .stage-panel.motion-artifact .demo-source-thumb { + border-color: rgba(209, 154, 69, 0.32); + color: #f0d79d; + background: rgba(24, 20, 16, 0.72); +} + +.workbench-v2.demo-mode .stage-panel.motion-product { + background: + radial-gradient(ellipse at 50% 48%, rgba(255, 255, 255, 0.96) 0 16%, rgba(216, 236, 238, 0.42) 48%, transparent 72%), + linear-gradient(135deg, rgba(248, 253, 252, 0.98), rgba(224, 236, 238, 0.92)), + #eef6f7; +} + +.workbench-v2.demo-mode .stage-panel.motion-specimen { + background: + radial-gradient(circle at 34% 34%, rgba(118, 193, 176, 0.18), transparent 24%), + radial-gradient(circle at 68% 62%, rgba(142, 99, 190, 0.14), transparent 28%), + linear-gradient(135deg, rgba(248, 253, 252, 0.96), rgba(226, 241, 238, 0.88)), + #eef6f7; +} + +.workbench-v2.demo-mode .stage-panel.motion-road .cell-fallback { + animation: demo-road-subject-drive 7.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; + transform-origin: 50% 70%; +} + +.workbench-v2.demo-mode .stage-panel.motion-aircraft .cell-fallback { + animation: demo-aircraft-subject-pass 7.2s cubic-bezier(0.36, 0, 0.28, 1) infinite; + transform-origin: 50% 50%; +} + +.workbench-v2.demo-mode .stage-panel.motion-vessel .cell-fallback { + animation: demo-vessel-subject-cruise 8.6s ease-in-out infinite; + transform-origin: 50% 62%; +} + +.workbench-v2.demo-mode .stage-panel.motion-specimen .cell-fallback { + animation: demo-specimen-subject-orbit 8.2s ease-in-out infinite; + transform-origin: 50% 54%; +} + +.workbench-v2.demo-mode .stage-panel.motion-product .cell-fallback { + animation: demo-product-subject-reveal 7.6s ease-in-out infinite; + transform-origin: 50% 56%; +} + +.workbench-v2.demo-mode .stage-panel.motion-artifact .cell-fallback { + animation: demo-artifact-subject-turntable 9s ease-in-out infinite; + transform-origin: 50% 62%; +} + +@media (min-width: 1600px) { + .workbench-v2 { + --side-width: 268px; + --tool-width: 316px; + --inspector-width: 360px; + --workbench-gap: 18px; + } + + .stage-zone .stage-panel { + --render-width: min(1120px, 93%); + --render-translate-x: 38px; + --render-translate-y: 30px; + } +} + +@media (min-width: 1920px) { + .workbench-v2 { + --side-width: 292px; + --tool-width: 338px; + --inspector-width: 382px; + --workbench-gap: 20px; + } + + .stage-zone .stage-panel { + --render-width: min(1280px, 92%); + --render-translate-x: 48px; + --render-translate-y: 36px; + } +} + +@media (max-width: 1279px) { + .workbench-v2 { + --side-width: 220px; + --tool-width: 262px; + --inspector-width: 304px; + --workbench-gap: 10px; + } + + .selection-shelf .cell-row { + min-height: 58px; + } + + .stage-zone .stage-panel { + --render-width: min(780px, 94%); + --render-translate-x: 18px; + --render-translate-y: 26px; + } + + .stage-zone .stage-title { + top: 22px; + left: 20px; + } + + .stage-zone .viewer-controls { + top: 16px; + right: 18px; + } + + .stage-zone .stage-toolbar { + left: 20px; + right: 20px; + bottom: 18px; + } +} + +@media (max-width: 980px) { + .studio-workbench-v2 { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + height: auto; + min-height: 100vh; + padding: 10px; + overflow: visible; + } + + .stage-zone, + .selection-shelf, + .command-zone { + position: static; + } + + .stage-zone .stage-panel { + position: relative; + inset: auto; + height: 520px; + } + + .selection-shelf, + .command-zone { + width: auto; + height: auto; + } + + .inspector-zone { + position: fixed; + inset: 12px; + width: auto; + max-width: none; + } + + .inspector-trigger { + top: 164px; + right: 18px; + } +} + +.workbench-v2 .workspace-drawer { + top: calc(var(--header-height) + var(--workbench-gap)); + right: auto; + bottom: var(--workbench-gap); + left: calc(var(--side-width) + (var(--workbench-gap) * 2)); + width: min(520px, calc(100% - var(--side-width) - var(--tool-width) - (var(--workbench-gap) * 5))); + max-height: none; + overflow: hidden; + background: rgba(255, 255, 255, 0.86); + box-shadow: + 0 28px 72px rgba(28, 58, 72, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.86); +} + +.workbench-v2 .workspace-drawer.drawer-library { + width: min(820px, calc(100% - var(--side-width) - var(--tool-width) - (var(--workbench-gap) * 5))); +} + +.workbench-v2 .workspace-drawer .drawer-content { + max-height: calc(100% - 104px); +} + +.workbench-v2 .workspace-drawer .drawer-meta { + grid-template-columns: 1fr 1fr; +} + +.workbench-v2 .workspace-drawer .drawer-meta span:last-child { + display: none; +} + +.workbench-v2 .stage-zone .viewer-controls { + right: 28px; +} + +.workbench-v2 .stage-zone .stage-status { + max-width: 230px; + opacity: 0.72; +} + +@media (max-width: 1279px) { + .workbench-v2 .workspace-drawer { + width: min(440px, calc(100% - 38px)); + } +} diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..8dfd8ca --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,1229 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { motion } from 'framer-motion' +import { Info, X } from 'lucide-react' +import { + CUSTOM_CELL_STORAGE_KEY, + DEFAULT_SETTINGS, + GALLERY_STORAGE_KEY, + GENERATION_HISTORY_STORAGE_KEY, + NOTES_STORAGE_KEY, + SETTINGS_STORAGE_KEY, + UI_STATE_STORAGE_KEY, + UI_STATE_STORAGE_VERSION, +} from './config/appConfig.js' +import { + createCustomCell, + getAllCells, + getAvailableOrganelleIds, + getCell, + getCellProfile, + getCustomCell, + getDefaultOrganelle, + getGenerationPrompt, + getOrganelleDetail, + getUploadPreviewFromCustomCells, + isLocalModelFile, +} from './domain/cellCatalog.js' +import { persistCustomCells } from './domain/cellPersistence.js' +import { normalizeSettings, normalizeUiState } from './domain/preferences.js' +import { downloadBlob, downloadJson, downloadText, getCanvasImageDataUrl } from './lib/downloads.js' +import { createImageThumbnailDataUrl, prepareImageForUpload } from './lib/imagePipeline.js' +import { listStoredModels, saveStoredModels } from './lib/modelStore.js' +import { deleteProject, listProjects, loadProject, saveProject } from './lib/projectStore.js' +import { loadStoredValue, storeValue } from './lib/storage.js' +import { analyzeAssetImage, create3dGeneration, get3dApiHealth, get3dServerLogs, getProviderLabel, getProviderPlan, uploadLocal3dModel, waitFor3dModel } from './services/modelApi.js' +import { BottomDeck } from './components/BottomDeck.jsx' +import { CenterStage } from './components/CenterStage.jsx' +import { DetailPanel } from './components/DetailPanel.jsx' +import { LeftSidebar } from './components/LeftSidebar.jsx' +import { StatusToast } from './components/StatusToast.jsx' +import { StudioHeader } from './components/StudioHeader.jsx' +import { WorkspaceDrawer } from './components/WorkspaceDrawer.jsx' +import './App.css' + +function mergeCustomModelRecords(primary = [], secondary = []) { + const order = [] + const byId = new Map() + + for (const cell of [...primary, ...secondary]) { + if (!cell?.id) continue + const existing = byId.get(cell.id) + if (!existing) order.push(cell.id) + + byId.set(cell.id, mergeCustomModelRecord(existing, cell)) + } + + return order.map((id) => byId.get(id)).filter(Boolean) +} + +function mergeCustomModelRecord(existing, next) { + if (!existing) return next + + const existingGeneration = existing.generation || {} + const nextGeneration = next.generation || {} + const existingStatus = String(existingGeneration.status || '').toLowerCase() + const canFillGeneratedAsset = !existingStatus || existingStatus === 'success' || existingStatus === 'local' + + return { + ...next, + ...existing, + imageUrl: existing.imageUrl || next.imageUrl || '', + generation: { + ...nextGeneration, + ...existingGeneration, + modelUrl: existingGeneration.modelUrl || (canFillGeneratedAsset ? nextGeneration.modelUrl || '' : ''), + rawModelUrl: existingGeneration.rawModelUrl || (canFillGeneratedAsset ? nextGeneration.rawModelUrl || '' : ''), + taskId: existingGeneration.taskId || nextGeneration.taskId || '', + }, + thumbnailUrl: existing.thumbnailUrl || next.thumbnailUrl || '', + intelligence: existing.intelligence || next.intelligence, + motionProfile: existing.motionProfile || next.motionProfile, + savedAt: existing.savedAt || next.savedAt, + updatedAt: existing.updatedAt || next.updatedAt, + } +} + +function applyAssetInsightToCell(cell, insight) { + if (!insight?.configured || insight.status !== 'success') return cell + + const objectName = String(insight.objectName || '').trim() + const displayName = objectName || cell.fullName || cell.name + + return { + ...cell, + name: displayName.length > 24 ? `${displayName.slice(0, 24)}...` : displayName, + fullName: displayName, + type: insight.categoryLabel || cell.type, + motionProfile: insight.categoryId || cell.motionProfile, + intelligence: insight, + generation: { + ...cell.generation, + message: cell.generation?.message || 'Image analyzed; waiting for generation.', + }, + } +} + +function App() { + const initialCustomCellsRef = useRef(loadStoredValue(CUSTOM_CELL_STORAGE_KEY, [])) + const initialUiStateRef = useRef(normalizeUiState(loadStoredValue(UI_STATE_STORAGE_KEY, {}))) + const [customCells, setCustomCells] = useState(() => initialCustomCellsRef.current) + const [selectedCell, setSelectedCell] = useState(() => initialUiStateRef.current.selectedCell) + const [selectedOrganelle, setSelectedOrganelle] = useState(() => initialUiStateRef.current.selectedOrganelle) + const [crossSection, setCrossSection] = useState(() => initialUiStateRef.current.crossSection) + const [activePanel, setActivePanel] = useState(null) + const [inspectorOpen, setInspectorOpen] = useState(false) + const [demoMode, setDemoMode] = useState(false) + const [toast, setToast] = useState('3D Model Studio ready') + const [favoriteKey, setFavoriteKey] = useState(() => initialUiStateRef.current.favoriteKey) + const [selectedMicroscope, setSelectedMicroscope] = useState(() => initialUiStateRef.current.selectedMicroscope) + const [uploadedImage, setUploadedImage] = useState(() => getUploadPreviewFromCustomCells(initialCustomCellsRef.current)) + const [sceneExporter, setSceneExporter] = useState(null) + const [compareCell, setCompareCell] = useState(() => initialUiStateRef.current.compareCell) + const [galleryItems, setGalleryItems] = useState(() => loadStoredValue(GALLERY_STORAGE_KEY, [])) + const [generationHistory, setGenerationHistory] = useState(() => loadStoredValue(GENERATION_HISTORY_STORAGE_KEY, [])) + const [notes, setNotes] = useState(() => loadStoredValue(NOTES_STORAGE_KEY, {})) + const [settings, setSettings] = useState(() => normalizeSettings(loadStoredValue(SETTINGS_STORAGE_KEY, DEFAULT_SETTINGS))) + const [apiHealth, setApiHealth] = useState(null) + const [serverLogs, setServerLogs] = useState({ entries: [], file: '', size: 0 }) + const [projects, setProjects] = useState([]) + const [modelLibraryHydrated, setModelLibraryHydrated] = useState(false) + const allCells = useMemo(() => getAllCells(customCells), [customCells]) + const latestUploadCell = useMemo(() => customCells.find((cell) => cell.custom && !cell.reference), [customCells]) + const refreshApiHealth = useCallback(async () => { + try { + const health = await get3dApiHealth() + setApiHealth({ ok: true, checkedAt: new Date().toISOString(), ...health }) + } catch (error) { + setApiHealth({ + ok: false, + checkedAt: new Date().toISOString(), + error: error instanceof Error ? error.message : 'API health check failed.', + }) + } + }, []) + const refreshServerLogs = useCallback(async () => { + try { + const logs = await get3dServerLogs(100) + setServerLogs({ ...logs, checkedAt: new Date().toISOString(), error: '' }) + } catch (error) { + setServerLogs({ + entries: [], + file: '', + size: 0, + checkedAt: new Date().toISOString(), + error: error instanceof Error ? error.message : 'Server logs could not be loaded.', + }) + } + }, []) + + useEffect(() => { + storeValue(GALLERY_STORAGE_KEY, galleryItems) + }, [galleryItems]) + + useEffect(() => { + storeValue(GENERATION_HISTORY_STORAGE_KEY, generationHistory) + }, [generationHistory]) + + useEffect(() => { + storeValue(NOTES_STORAGE_KEY, notes) + }, [notes]) + + useEffect(() => { + storeValue(SETTINGS_STORAGE_KEY, settings) + }, [settings]) + + useEffect(() => { + persistCustomCells(customCells) + }, [customCells]) + + useEffect(() => { + let cancelled = false + + listStoredModels() + .then((storedModels) => { + if (cancelled) return + + if (storedModels.length > 0) { + setCustomCells((current) => { + const merged = mergeCustomModelRecords(current, storedModels) + persistCustomCells(merged) + return merged + }) + setUploadedImage((current) => current || getUploadPreviewFromCustomCells(storedModels)) + } + + setModelLibraryHydrated(true) + }) + .catch(() => { + if (!cancelled) setModelLibraryHydrated(true) + }) + + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + if (!modelLibraryHydrated) return + saveStoredModels(customCells) + }, [customCells, modelLibraryHydrated]) + + useEffect(() => { + storeValue(UI_STATE_STORAGE_KEY, { + selectedCell, + selectedOrganelle, + selectedMicroscope, + compareCell, + crossSection, + favoriteKey, + uiStateVersion: UI_STATE_STORAGE_VERSION, + }) + }, [compareCell, crossSection, favoriteKey, selectedCell, selectedMicroscope, selectedOrganelle]) + + useEffect(() => { + if (activePanel) setInspectorOpen(false) + if (activePanel === 'Settings') refreshApiHealth() + if (activePanel === 'Logs') { + refreshApiHealth() + refreshServerLogs() + } + }, [activePanel, refreshApiHealth, refreshServerLogs]) + + useEffect(() => { + if (!uploadedImage) setUploadedImage(getUploadPreviewFromCustomCells(customCells)) + }, [customCells, uploadedImage]) + + useEffect(() => { + listProjects() + .then(setProjects) + .catch(() => setProjects([])) + }, []) + + useEffect(() => { + if (!demoMode) return undefined + + const presentationCells = allCells.filter((cell) => !cell.reference).slice(0, 7) + if (presentationCells.length === 0) return undefined + let index = Math.max(0, presentationCells.findIndex((cell) => cell.id === selectedCell)) + + const timer = window.setInterval(() => { + index = (index + 1) % presentationCells.length + const nextCell = presentationCells[index] + setSelectedCell(nextCell.id) + setSelectedOrganelle(getDefaultOrganelle(nextCell.id, customCells)) + setCompareCell(getCellProfile(nextCell.id, customCells).compareTarget) + setToast(`Presentation: ${nextCell.name}`) + }, 9200) + + return () => window.clearInterval(timer) + }, [allCells, customCells, demoMode, selectedCell]) + + useEffect(() => { + if (allCells.some((cell) => cell.id === selectedCell)) return + setSelectedCell('plant') + setSelectedOrganelle(getDefaultOrganelle('plant', customCells)) + setCompareCell(getCellProfile('plant', customCells).compareTarget) + setToast('Saved model was missing; starter model loaded') + }, [allCells, customCells, selectedCell]) + + useEffect(() => { + const available = getAvailableOrganelleIds(selectedCell, customCells) + if (available.includes(selectedOrganelle)) return + setSelectedOrganelle(getDefaultOrganelle(selectedCell, customCells)) + }, [customCells, selectedCell, selectedOrganelle]) + + function handleSelectCell(cellId) { + const nextCell = getCell(cellId, customCells) + setSelectedCell(cellId) + setSelectedOrganelle(getDefaultOrganelle(cellId, customCells)) + setInspectorOpen(false) + setCompareCell((current) => (current === cellId ? getCellProfile(cellId, customCells).compareTarget : current)) + if (nextCell.custom) setUploadedImage({ name: nextCell.name, url: nextCell.imageUrl || nextCell.thumbnailUrl || '' }) + setToast(`${nextCell.name} loaded`) + } + + function handleStageOrganelleSelect(organelleId) { + setSelectedOrganelle(organelleId) + setActivePanel(null) + setInspectorOpen(true) + } + + function openInspector() { + setActivePanel(null) + setInspectorOpen(true) + } + + function toggleDemoMode() { + setDemoMode((current) => { + const next = !current + if (next) { + setActivePanel(null) + setInspectorOpen(false) + } + setToast(next ? 'Demo mode enabled' : 'Workbench mode restored') + return next + }) + } + + async function handleExport() { + const cell = getCell(selectedCell, customCells) + const detail = getOrganelleDetail(selectedCell, selectedOrganelle, customCells) + + if (!sceneExporter) { + downloadJson(`${selectedCell}-model-export.json`, { + cell, + selectedOrganelle, + detail, + crossSection, + selectedMicroscope, + exportedAt: new Date().toISOString(), + fallbackReason: 'WebGL model exporter is not available in this browser.', + }) + setToast('WebGL unavailable; metadata exported') + return + } + + setToast('Preparing GLB export') + try { + const glb = await sceneExporter() + downloadBlob(`${selectedCell}-${selectedOrganelle}.glb`, glb) + setToast(`${cell.name} GLB downloaded`) + } catch (error) { + console.error(error) + downloadJson(`${selectedCell}-model-export.json`, { + cell, + selectedOrganelle, + detail, + crossSection, + selectedMicroscope, + exportedAt: new Date().toISOString(), + fallbackReason: error instanceof Error ? error.message : 'GLB export failed.', + }) + setToast('GLB failed; metadata exported') + } + } + + function updateCustomCell(cellId, patch) { + setCustomCells((current) => { + const next = current.map((cell) => ( + cell.id === cellId + ? { + ...cell, + ...(typeof patch === 'function' ? patch(cell) : patch), + } + : cell + )) + persistCustomCells(next) + return next + }) + } + + async function analyzeUploadedAsset(imageDataUrl, fileName) { + try { + const insight = await analyzeAssetImage({ imageDataUrl, fileName }) + if (!insight?.configured) return null + return insight + } catch (error) { + console.warn(error) + return null + } + } + + function addGenerationHistory(entry) { + const nextEntry = { + id: entry.id || `history-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + startedAt: new Date().toISOString(), + status: 'queued', + ...entry, + } + setGenerationHistory((items) => [nextEntry, ...items].slice(0, 40)) + return nextEntry.id + } + + function updateGenerationHistory(entryId, patch) { + setGenerationHistory((items) => items.map((item) => ( + item.id === entryId + ? { + ...item, + ...(typeof patch === 'function' ? patch(item) : patch), + } + : item + ))) + } + + async function generateCustomCellModel(customCell, imageUrl, fileName, requestedProvider = settings.generationMode) { + const providers = getProviderPlan(requestedProvider) + const errors = [] + + for (const provider of providers) { + const label = getProviderLabel(provider) + const startedAt = Date.now() + const historyId = addGenerationHistory({ + cellId: customCell.id, + cellName: customCell.name, + provider, + requestedProvider, + sourceImageUrl: imageUrl, + status: 'queued', + message: `Queued ${label} generation.`, + }) + + if (provider === 'cinematic') { + updateCustomCell(customCell.id, (cell) => ({ + generation: { + ...cell.generation, + provider: 'cinematic', + requestedProvider, + status: 'local', + progress: 100, + modelUrl: '', + rawModelUrl: '', + message: 'JS depth relief is ready.', + }, + })) + updateGenerationHistory(historyId, { + status: 'success', + progress: 100, + finishedAt: new Date().toISOString(), + durationMs: Date.now() - startedAt, + message: 'JS depth relief is ready.', + }) + setToast(`${customCell.name} JS depth preview ready`) + return + } + + try { + updateCustomCell(customCell.id, (cell) => ({ + generation: { + ...cell.generation, + provider, + requestedProvider, + status: 'uploading', + progress: 0, + modelUrl: '', + rawModelUrl: '', + message: `Sending image to ${label}.`, + }, + })) + updateGenerationHistory(historyId, { + status: 'uploading', + progress: 0, + message: `Sending image to ${label}.`, + }) + setToast(`Creating ${label} image-to-3D task`) + + const task = await create3dGeneration({ + provider, + imageDataUrl: imageUrl, + fileName, + prompt: getGenerationPrompt(customCell), + modelId: provider === 'fal' ? settings.falModelId : undefined, + }) + + updateCustomCell(customCell.id, (cell) => ({ + generation: { + ...cell.generation, + provider, + requestedProvider, + status: 'processing', + progress: 0, + taskId: task.taskId, + message: `${label} is generating the GLB model.`, + }, + })) + updateGenerationHistory(historyId, { + status: 'processing', + progress: 0, + taskId: task.taskId, + message: `${label} is generating the GLB model.`, + }) + setToast(`${label} task started: ${String(task.taskId).slice(0, 8)}`) + + const finalStatus = await waitFor3dModel(task.taskId, provider, (status) => { + updateCustomCell(customCell.id, (cell) => ({ + generation: { + ...cell.generation, + provider, + requestedProvider, + status: status.status || 'processing', + progress: status.progress ?? cell.generation?.progress ?? null, + taskId: task.taskId, + message: status.progress ? `${label} progress ${status.progress}%` : `${label} status: ${status.status || 'processing'}`, + }, + })) + updateGenerationHistory(historyId, { + status: status.status || 'processing', + progress: status.progress ?? null, + taskId: task.taskId, + message: status.progress ? `${label} progress ${status.progress}%` : `${label} status: ${status.status || 'processing'}`, + }) + }) + + updateCustomCell(customCell.id, (cell) => ({ + generation: { + ...cell.generation, + provider, + requestedProvider, + status: 'success', + progress: 100, + taskId: task.taskId, + modelUrl: finalStatus.modelUrl, + rawModelUrl: finalStatus.rawModelUrl, + message: `${label} GLB loaded.`, + }, + })) + updateGenerationHistory(historyId, { + status: 'success', + progress: 100, + taskId: task.taskId, + modelUrl: finalStatus.modelUrl, + rawModelUrl: finalStatus.rawModelUrl, + finishedAt: new Date().toISOString(), + durationMs: Date.now() - startedAt, + message: `${label} GLB loaded.`, + }) + setToast(`${customCell.name} ${label} 3D model ready`) + return + } catch (error) { + const message = error instanceof Error ? error.message : `${label} generation failed.` + errors.push(`${label}: ${message}`) + updateGenerationHistory(historyId, { + status: 'failed', + progress: null, + finishedAt: new Date().toISOString(), + durationMs: Date.now() - startedAt, + message, + }) + + if (provider !== providers[providers.length - 1]) { + updateCustomCell(customCell.id, (cell) => ({ + generation: { + ...cell.generation, + provider, + requestedProvider, + status: 'processing', + progress: null, + message: `${label} failed; trying ${getProviderLabel(providers[providers.indexOf(provider) + 1])}.`, + }, + })) + setToast(`${label} failed; trying backup provider`) + } + } + } + + throw new Error(errors.join(' | ')) + } + + async function handleRetryGeneration(cellId) { + const cell = getCustomCell(cellId, customCells) + if (!cell?.imageUrl) { + setToast('No source image to retry') + return + } + + setSelectedCell(cell.id) + setSelectedOrganelle(getDefaultOrganelle(cell.id, customCells)) + setToast('Retrying 3D generation') + + try { + const retryMode = settings.generationMode === 'local' ? 'cinematic' : settings.generationMode + await generateCustomCellModel(cell, cell.imageUrl, `${cell.name}.png`, retryMode) + } catch (error) { + console.error(error) + updateCustomCell(cell.id, (current) => ({ + generation: { + ...current.generation, + requestedProvider: settings.generationMode, + status: 'failed', + progress: null, + modelUrl: '', + rawModelUrl: '', + message: error instanceof Error ? error.message : '3D generation failed.', + }, + })) + setToast(error instanceof Error ? error.message : 'Image-to-3D generation failed') + } + } + + async function handleUploadImage(file) { + if (isLocalModelFile(file)) { + await handleUploadLocalModel(file) + return + } + + setToast('Uploading image for 3D generation') + let customCell = null + try { + const requestedMode = settings.generationMode === 'local' ? 'cinematic' : settings.generationMode + if (settings.generationMode === 'local') setToast('Local GLB mode needs a model file; using JS Depth') + const { displayUrl, generationUrl } = await prepareImageForUpload(file) + const thumbnailUrl = await createImageThumbnailDataUrl(displayUrl) + setToast('Analyzing source image') + const assetInsight = await analyzeUploadedAsset(generationUrl, file.name) + customCell = applyAssetInsightToCell(createCustomCell(file.name, displayUrl, { + provider: requestedMode, + requestedProvider: requestedMode, + thumbnailUrl: thumbnailUrl || displayUrl, + type: requestedMode === 'cinematic' ? 'JS Depth preview asset' : undefined, + }), assetInsight) + customCell.generation = { + ...customCell.generation, + provider: requestedMode, + requestedProvider: requestedMode, + status: 'uploading', + progress: 0, + message: requestedMode === 'cinematic' ? 'Building browser-side JS depth relief.' : 'Sending image to backend.', + } + const nextCustomCells = [customCell, ...customCells].slice(0, 8) + persistCustomCells(nextCustomCells) + + setCustomCells(nextCustomCells) + setUploadedImage({ name: file.name, url: displayUrl }) + setSelectedCell(customCell.id) + setSelectedOrganelle(getDefaultOrganelle(customCell.id, nextCustomCells)) + setCompareCell(customCell.template) + setActivePanel('Library') + await generateCustomCellModel(customCell, generationUrl, file.name, requestedMode) + } catch (error) { + console.error(error) + if (customCell) { + updateCustomCell(customCell.id, (cell) => ({ + generation: { + ...cell.generation, + requestedProvider: settings.generationMode, + status: 'failed', + progress: null, + message: error instanceof Error ? error.message : '3D generation failed.', + }, + })) + } + setToast(error instanceof Error ? error.message : 'Image-to-3D generation failed') + } + } + + async function handleUploadLocalModel(file) { + setToast('Importing local 3D model') + let customCell = null + let historyId = '' + const startedAt = Date.now() + + try { + customCell = createCustomCell(file.name, '', { + provider: 'local', + requestedProvider: 'local', + type: 'Local 3D Model', + status: 'uploading', + progress: 0, + message: 'Saving model to local cache.', + }) + historyId = addGenerationHistory({ + cellId: customCell.id, + cellName: customCell.name, + provider: 'local', + requestedProvider: 'local', + status: 'uploading', + sourceImageUrl: '', + message: 'Importing local GLB/GLTF.', + }) + const nextCustomCells = [customCell, ...customCells].slice(0, 8) + persistCustomCells(nextCustomCells) + + setCustomCells(nextCustomCells) + setUploadedImage({ name: file.name, url: '' }) + setSelectedCell(customCell.id) + setSelectedOrganelle(getDefaultOrganelle(customCell.id, nextCustomCells)) + setCompareCell(customCell.template) + setActivePanel('Library') + + const localModel = await uploadLocal3dModel(file) + updateCustomCell(customCell.id, (cell) => ({ + generation: { + ...cell.generation, + provider: 'local', + requestedProvider: 'local', + status: 'success', + progress: 100, + taskId: localModel.taskId, + modelUrl: localModel.modelUrl, + rawModelUrl: '', + message: 'Local GLB loaded from disk cache.', + }, + })) + updateGenerationHistory(historyId, { + status: 'success', + progress: 100, + taskId: localModel.taskId, + modelUrl: localModel.modelUrl, + finishedAt: new Date().toISOString(), + durationMs: Date.now() - startedAt, + message: 'Local GLB loaded from disk cache.', + }) + setToast(`${customCell.name} local 3D model ready`) + } catch (error) { + console.error(error) + if (customCell) { + updateCustomCell(customCell.id, (cell) => ({ + generation: { + ...cell.generation, + provider: 'local', + requestedProvider: 'local', + status: 'failed', + progress: null, + message: error instanceof Error ? error.message : 'Local model import failed.', + }, + })) + } + if (historyId) { + updateGenerationHistory(historyId, { + status: 'failed', + progress: null, + finishedAt: new Date().toISOString(), + durationMs: Date.now() - startedAt, + message: error instanceof Error ? error.message : 'Local model import failed.', + }) + } + setToast(error instanceof Error ? error.message : 'Local model import failed') + } + } + + async function handleSaveGallery() { + const cell = getCell(selectedCell, customCells) + const detail = getOrganelleDetail(selectedCell, selectedOrganelle, customCells) + const generation = cell.custom ? cell.generation : null + const thumbnailUrl = getCanvasImageDataUrl({ scale: 1, maxWidth: 420 }) || cell.imageUrl || '' + const item = { + id: `${Date.now()}-${selectedCell}-${selectedOrganelle}`, + title: `${cell.name} / ${detail.title}`, + cellId: selectedCell, + organelleId: selectedOrganelle, + microscope: selectedMicroscope, + crossSection, + generationProvider: generation?.provider || 'built-in', + modelUrl: generation?.modelUrl || '', + thumbnailUrl, + createdAt: new Date().toISOString(), + } + setGalleryItems((items) => [item, ...items].slice(0, 12)) + setToast(thumbnailUrl ? 'View saved with screenshot' : 'View saved without screenshot') + } + + function handleClearGallery() { + setGalleryItems([]) + setToast('Gallery cleared') + } + + function handleRestoreGalleryItem(item) { + if (!item) return + handleSelectCell(item.cellId) + setSelectedOrganelle(item.organelleId) + setSelectedMicroscope(item.microscope) + if (typeof item.crossSection === 'boolean') setCrossSection(item.crossSection) + setToast('Saved gallery view restored') + } + + function handleRenameGalleryItem(itemId, title) { + const nextTitle = title.trim() + if (!nextTitle) return + setGalleryItems((items) => items.map((item) => (item.id === itemId ? { ...item, title: nextTitle } : item))) + setToast('Gallery item renamed') + } + + function handleDeleteGalleryItem(itemId) { + setGalleryItems((items) => items.filter((item) => item.id !== itemId)) + setToast('Gallery item removed') + } + + function handleDownloadGalleryImage(item) { + if (!item?.thumbnailUrl) { + setToast('No screenshot stored for this view') + return + } + const link = document.createElement('a') + link.href = item.thumbnailUrl + link.download = `${item.cellId}-${item.organelleId}-gallery.png` + link.click() + setToast('Gallery screenshot downloaded') + } + + function handleExportGallery() { + downloadJson('3d-model-studio-gallery.json', galleryItems) + setToast('Gallery JSON exported') + } + + function handleDeleteCustomCell(cellId) { + const deleted = customCells.find((cell) => cell.id === cellId) + if (!deleted) return + + const nextCustomCells = customCells.filter((cell) => cell.id !== cellId) + persistCustomCells(nextCustomCells) + setCustomCells(nextCustomCells) + setGalleryItems((items) => items.filter((item) => item.cellId !== cellId)) + setGenerationHistory((items) => items.filter((item) => item.cellId !== cellId)) + setNotes((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${cellId}:`)))) + + if (selectedCell === cellId) { + setSelectedCell('plant') + setSelectedOrganelle(getDefaultOrganelle('plant', nextCustomCells)) + setCompareCell(getCellProfile('plant', nextCustomCells).compareTarget) + setInspectorOpen(false) + } + + setUploadedImage(getUploadPreviewFromCustomCells(nextCustomCells)) + setToast(`${deleted.name} removed`) + } + + function handleUpdateNote(noteKey, value) { + setNotes((current) => { + const next = { ...current } + if (value.trim()) next[noteKey] = value + else delete next[noteKey] + return next + }) + } + + function buildNotebookText() { + const cell = getCell(selectedCell, customCells) + const detail = getOrganelleDetail(selectedCell, selectedOrganelle, customCells) + const profile = getCellProfile(selectedCell, customCells) + + return [ + `# ${cell.name} - ${detail.title}`, + '', + `Type: ${cell.type}`, + `View: ${selectedMicroscope}`, + '', + '## Observation', + profile.summary, + '', + '## Part Detail', + `${detail.title}: ${detail.subtitle}. Scale: ${detail.size}. Position: ${detail.location}. Visibility: ${detail.visible}.`, + '', + '## Narration', + `${cell.name} highlights ${detail.title.toLowerCase()} as a key structure. ${detail.note}`, + ].join('\n') + } + + function handleGenerateNote() { + const noteKey = `${selectedCell}:${selectedOrganelle}` + const generated = buildNotebookText() + setNotes((current) => ({ ...current, [noteKey]: current[noteKey]?.trim() ? `${current[noteKey]}\n\n${generated}` : generated })) + setToast('Notebook draft generated') + } + + async function handleCopyNote() { + const noteKey = `${selectedCell}:${selectedOrganelle}` + const text = notes[noteKey] || buildNotebookText() + try { + await navigator.clipboard.writeText(text) + setToast('Notebook copied') + } catch { + setToast('Clipboard unavailable') + } + } + + function handleExportNote() { + const noteKey = `${selectedCell}:${selectedOrganelle}` + const text = notes[noteKey] || buildNotebookText() + downloadText(`${selectedCell}-${selectedOrganelle}-notes.md`, text, 'text/markdown;charset=utf-8') + setToast('Notebook Markdown exported') + } + + async function handleCopyText(text, successMessage = 'Copied') { + try { + await navigator.clipboard.writeText(text) + setToast(successMessage) + } catch { + setToast('Clipboard unavailable') + } + } + + function handleClearWorkspaceCache() { + if (!window.confirm('Clear uploaded models, gallery items, and notes from this browser?')) return + setCustomCells([]) + setGalleryItems([]) + setGenerationHistory([]) + setNotes({}) + setUploadedImage(null) + setSelectedCell('plant') + setSelectedOrganelle(getDefaultOrganelle('plant', [])) + setCompareCell(getCellProfile('plant', []).compareTarget) + setToast('Workspace cache cleared') + } + + function handleResetWorkspace() { + if (!window.confirm('Reset the workspace, settings, saved views, notes, and uploaded models?')) return + const normalizedDefaults = normalizeSettings(DEFAULT_SETTINGS) + setSettings(normalizedDefaults) + setCustomCells([]) + setGalleryItems([]) + setGenerationHistory([]) + setNotes({}) + setFavoriteKey('') + setUploadedImage(null) + setSelectedCell('plant') + setSelectedOrganelle(getDefaultOrganelle('plant', [])) + setSelectedMicroscope('Studio Preview') + setCrossSection(true) + setCompareCell(getCellProfile('plant', []).compareTarget) + setActivePanel(null) + setInspectorOpen(false) + setToast('Workspace reset') + } + + function buildProjectSnapshot(name) { + return { + name, + thumbnailUrl: getCanvasImageDataUrl({ scale: 1, maxWidth: 420 }) || getCell(selectedCell, customCells).imageUrl || '', + summary: `${getCell(selectedCell, customCells).name} / ${getOrganelleDetail(selectedCell, selectedOrganelle, customCells).title}`, + state: { + customCells, + galleryItems, + generationHistory, + notes, + settings, + ui: { + selectedCell, + selectedOrganelle, + selectedMicroscope, + compareCell, + crossSection, + favoriteKey, + }, + }, + } + } + + async function refreshProjects() { + try { + setProjects(await listProjects()) + } catch { + setProjects([]) + } + } + + async function handleSaveProject() { + const fallbackName = `${getCell(selectedCell, customCells).name} Workspace` + const name = window.prompt('Project name', fallbackName) + if (name === null) return + const project = await saveProject(buildProjectSnapshot(name.trim() || fallbackName)) + await refreshProjects() + setToast(`${project.name} saved`) + } + + async function handleLoadProject(projectId) { + const project = await loadProject(projectId) + if (!project?.state) { + setToast('Project could not be loaded') + return + } + + const nextCustomCells = project.state.customCells || [] + persistCustomCells(nextCustomCells) + const ui = project.state.ui || {} + const nextSettings = normalizeSettings(project.state.settings || DEFAULT_SETTINGS) + setCustomCells(nextCustomCells) + setGalleryItems(project.state.galleryItems || []) + setGenerationHistory(project.state.generationHistory || []) + setNotes(project.state.notes || {}) + setSettings(nextSettings) + setSelectedCell(ui.selectedCell || 'plant') + setSelectedOrganelle(ui.selectedOrganelle || getDefaultOrganelle(ui.selectedCell || 'plant', nextCustomCells)) + setSelectedMicroscope(ui.selectedMicroscope || 'Studio Preview') + setCompareCell(ui.compareCell || getCellProfile(ui.selectedCell || 'plant', nextCustomCells).compareTarget) + setCrossSection(typeof ui.crossSection === 'boolean' ? ui.crossSection : true) + setFavoriteKey(ui.favoriteKey || '') + setUploadedImage(getUploadPreviewFromCustomCells(nextCustomCells)) + setActivePanel(null) + setInspectorOpen(false) + setToast(`${project.name} loaded`) + } + + async function handleDeleteProject(projectId) { + await deleteProject(projectId) + await refreshProjects() + setToast('Project deleted') + } + + function handleExportProject(project) { + downloadJson(`${project.name || '3d-model-studio-project'}.modelstudio.json`, project) + setToast('Project package exported') + } + + function handleClearGenerationHistory() { + setGenerationHistory([]) + setToast('Generation history cleared') + } + + function handleExportDiagnostics() { + downloadJson(`3d-model-studio-diagnostics-${Date.now()}.json`, { + exportedAt: new Date().toISOString(), + selected: { + cell: selectedCell, + organelle: selectedOrganelle, + microscope: selectedMicroscope, + compareCell, + crossSection, + }, + settings, + apiHealth, + serverLogs, + generationHistory, + galleryCount: galleryItems.length, + noteCount: Object.keys(notes).length, + customCells: customCells.map((cell) => ({ + id: cell.id, + name: cell.name, + template: cell.template, + provider: cell.generation?.provider, + requestedProvider: cell.generation?.requestedProvider, + status: cell.generation?.status, + progress: cell.generation?.progress, + taskId: cell.generation?.taskId, + modelUrl: cell.generation?.modelUrl, + message: cell.generation?.message, + })), + }) + setToast('Diagnostics exported') + } + + async function handleRunProviderCompare(cellId = selectedCell) { + const sourceCell = getCustomCell(cellId, customCells) + if (!sourceCell?.imageUrl) { + setToast('Provider compare needs a custom model with a source image') + return + } + + const providers = apiHealth?.providers?.fal?.configured ? ['rodin', 'tripo', 'fal', 'cinematic'] : ['rodin', 'tripo', 'cinematic'] + const comparisonCells = providers.map((provider) => createCustomCell(`${sourceCell.name} ${getProviderLabel(provider)} compare.png`, sourceCell.imageUrl, { + provider, + requestedProvider: provider, + type: `${getProviderLabel(provider)} provider comparison`, + })) + + setCustomCells((current) => { + const next = [...comparisonCells, ...current].slice(0, 12) + persistCustomCells(next) + return next + }) + setSelectedCell(comparisonCells[0].id) + setSelectedOrganelle(getDefaultOrganelle(comparisonCells[0].id, comparisonCells)) + setActivePanel('Library') + setToast(`Provider compare started: ${providers.map(getProviderLabel).join(', ')}`) + + await Promise.all(comparisonCells.map(async (candidate) => { + try { + await generateCustomCellModel(candidate, sourceCell.imageUrl, `${candidate.name}.png`, candidate.generation.provider) + } catch (error) { + console.error(error) + updateCustomCell(candidate.id, (cell) => ({ + generation: { + ...cell.generation, + status: 'failed', + progress: null, + message: error instanceof Error ? error.message : 'Provider comparison failed.', + }, + })) + } + })) + setToast('Provider compare finished') + } + + function handleOpenCompare(cellId) { + setCompareCell(cellId) + setActivePanel('Compare') + setToast(`${getCell(selectedCell, customCells).name} compared with ${getCell(cellId, customCells).name}`) + } + + return ( +
+ + + setActivePanel(null)} + onSelectCell={handleSelectCell} + onSelectOrganelle={setSelectedOrganelle} + onSetCompareCell={(cellId) => { + setCompareCell(cellId) + setToast(`${getCell(cellId, customCells).name} set as comparison target`) + }} + onSaveGallery={handleSaveGallery} + onClearGallery={handleClearGallery} + onRestoreGalleryItem={handleRestoreGalleryItem} + onRenameGalleryItem={handleRenameGalleryItem} + onDeleteGalleryItem={handleDeleteGalleryItem} + onDownloadGalleryImage={handleDownloadGalleryImage} + onExportGallery={handleExportGallery} + onDeleteCustomCell={handleDeleteCustomCell} + onClearGenerationHistory={handleClearGenerationHistory} + onUpdateNote={handleUpdateNote} + onGenerateNote={handleGenerateNote} + onCopyNote={handleCopyNote} + onExportNote={handleExportNote} + onUpdateSettings={setSettings} + onSetCrossSection={setCrossSection} + onExport={handleExport} + exportAvailable={Boolean(sceneExporter)} + exportReason={sceneExporter ? 'GLB export ready' : 'No mounted WebGL model is available for GLB export.'} + apiHealth={apiHealth} + serverLogs={serverLogs} + onRefreshApiHealth={refreshApiHealth} + onRefreshServerLogs={refreshServerLogs} + onExportDiagnostics={handleExportDiagnostics} + onClearWorkspaceCache={handleClearWorkspaceCache} + onResetWorkspace={handleResetWorkspace} + onSaveProject={handleSaveProject} + onLoadProject={handleLoadProject} + onDeleteProject={handleDeleteProject} + onExportProject={handleExportProject} + onRunProviderCompare={handleRunProviderCompare} + onCopyText={handleCopyText} + onNotify={setToast} + /> + + {demoMode && ( + + )} +
+
+ +
+ +
+ +
+ + + + {inspectorOpen && ( + <> + + +
+ + )} + +
+ setSettings((current) => ({ ...current, generationMode }))} + onUploadImage={handleUploadImage} + compareCell={compareCell} + customCells={customCells} + onCompare={handleOpenCompare} + onOpenGenerationCell={handleSelectCell} + onNotify={setToast} + /> +
+ +
+
+ ) +} + +export default App diff --git a/src/assets/cell-plant-render.png b/src/assets/cell-plant-render.png new file mode 100644 index 0000000..7f0915a Binary files /dev/null and b/src/assets/cell-plant-render.png differ diff --git a/src/assets/hero.png b/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/src/assets/hero.png differ diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/vite.svg b/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/src/components/BottomDeck.jsx b/src/components/BottomDeck.jsx new file mode 100644 index 0000000..37ed08f --- /dev/null +++ b/src/components/BottomDeck.jsx @@ -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 ( +
+
+
+ Asset Source + {latestUploadCell ? 5 : 4} +
+
+ Provider +
+ {GENERATION_MODE_OPTIONS.map((mode) => ( + + ))} +
+
+
+ {MICROSCOPE_IMAGES.map((item) => ( + + ))} + {latestUploadCell ? ( + <> + + + + ) : ( + + )} + { + const file = event.target.files?.[0] + if (!file) return + onUploadImage(file) + event.target.value = '' + }} + /> +
+
+ +
+
+ Compare Models + 2 +
+ +
+ +
+ ) +} diff --git a/src/components/CellThumb.jsx b/src/components/CellThumb.jsx new file mode 100644 index 0000000..b3f6557 --- /dev/null +++ b/src/components/CellThumb.jsx @@ -0,0 +1,12 @@ +export function CellThumb({ cell, selected }) { + const previewUrl = cell.thumbnailUrl || cell.imageUrl || '' + + return ( + + + + ) +} diff --git a/src/components/CenterStage.jsx b/src/components/CenterStage.jsx new file mode 100644 index 0000000..859c94d --- /dev/null +++ b/src/components/CenterStage.jsx @@ -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 ( +
+ View Mode +
+ {modes.map((mode) => { + const Icon = mode.icon + return ( + + ) + })} +
+ {supportsPartControls && ( + + )} +
+ ) +} + +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 = ( + + ) + + 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 ( +
+
+
+

{cell.name}

+

{cell.type}

+
+
+ + {demoMode && } + {demoMode && } + {!demoMode && } +
+ + {isCinematicCell ? ( + + ) : ( + <> + + {!generationFailed && ( + + )} + + )} + +
+ {referenceImageUrl && ( +
+ {`${cell.name} + {referenceLabel} +
+ )} + {generationPending && ( +
+ {generation.status === 'uploading' ? `Uploading to ${generationProviderLabel}` : `Generating with ${generationProviderLabel}`} + {generation.message || 'Waiting for AI-generated GLB...'} +
+ +
+
+ )} + {generationFailed && ( +
+ {generationFailureTitle} + {generation.message || 'The saved upload failed before a GLB was returned.'} + +
+ )} + {activeViewerError && !generationFailed && ( +
+ 3D preview unavailable + {generatedModelUrl ? 'The saved GLB could not be loaded. Showing the saved source image or fallback model instead.' : activeViewerError} + {cell.custom && !cell.reference && cell.imageUrl && } +
+ )} +
+ {stageStatusText} +
+ {capturePulse &&
} +
+ + {supportsPartControls && ( + + )} + {supportsPartControls && ( + + )} + + + + + +
+
+ ) +} + +function PresentationMotionField({ profile }) { + if (!['road', 'aircraft', 'vessel', 'artifact', 'product', 'specimen'].includes(profile)) return null + + return ( + + ) +} + +function ModelQualityCard({ quality }) { + return ( + + ) +} + +function DemoShowcaseOverlay({ cell, quality, referenceImageUrl, motionProfile, sceneProfile }) { + return ( +
+
+ 3D Model Studio + {cell.name} + {quality.providerLabel} · {quality.hasGlb ? 'GLB asset' : quality.status} · {quality.verdict} · {motionProfile.label} +

{sceneProfile.summary}

+
+ {sceneProfile.badges.map((badge) => ( + {badge} + ))} +
+
+
+ {quality.score}score + {formatBytes(quality.fileBytes)}file + {formatNumber(quality.triangleCount)}triangles + {quality.textureCount}textures + {formatDuration(quality.durationMs)}time +
+ {referenceImageUrl && ( +
+ {`${cell.name} + source +
+ )} +
+ ) +} diff --git a/src/components/DetailPanel.jsx b/src/components/DetailPanel.jsx new file mode 100644 index 0000000..66cbb55 --- /dev/null +++ b/src/components/DetailPanel.jsx @@ -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 ( + + ) +} diff --git a/src/components/GenerationTaskCenter.jsx b/src/components/GenerationTaskCenter.jsx new file mode 100644 index 0000000..1da7044 --- /dev/null +++ b/src/components/GenerationTaskCenter.jsx @@ -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 ( +
+
+ Generation Queue + {activeCount || tasks.length} +
+ {selectedCustomCell && ( +
+ +
+ )} + {tasks.length === 0 ? ( +
+ + Upload an image or GLB to start a model job. +
+ ) : ( +
+ {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 ( +
+ + + {failed ? : done ? : } + + {failed && ( + + )} +
+ ) + })} +
+ )} + {recentHistory.length > 0 && ( +
+ History + {recentHistory.map((item) => ( + + ))} +
+ )} +
+ ) +} + +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' +} diff --git a/src/components/LeftSidebar.jsx b/src/components/LeftSidebar.jsx new file mode 100644 index 0000000..cbe9398 --- /dev/null +++ b/src/components/LeftSidebar.jsx @@ -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 ( +
+ + {canDelete && ( + + )} +
+ ) + } + + return ( + + ) +} + +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' +} diff --git a/src/components/StatusToast.jsx b/src/components/StatusToast.jsx new file mode 100644 index 0000000..a02de7b --- /dev/null +++ b/src/components/StatusToast.jsx @@ -0,0 +1,8 @@ +export function StatusToast({ message }) { + return ( +
+ + {message} +
+ ) +} diff --git a/src/components/StudioHeader.jsx b/src/components/StudioHeader.jsx new file mode 100644 index 0000000..2875261 --- /dev/null +++ b/src/components/StudioHeader.jsx @@ -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 ( +
+
+
+ +
+
+ {text.title} + {text.subtitle} +
+
+ + +
+ ) +} diff --git a/src/components/WorkspaceDrawer.jsx b/src/components/WorkspaceDrawer.jsx new file mode 100644 index 0000000..1541af9 --- /dev/null +++ b/src/components/WorkspaceDrawer.jsx @@ -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 ( +
+ +
+
+ + {item.fullName || item.name} + {getAssetKind(item)} · {providerLabel} + + + {status === 'ready' || status === 'reference' ? : status === 'failed' ? : } + {status} + +
+
+ {modelUrl ? 'GLB' : 'Preview'}asset + {getAssetRuntime(item, generationHistory)}runtime + {taskId ? String(taskId).slice(0, 8) : 'none'}task +
+ {modelUrl || item.referenceSource || item.type || 'Procedural preview only'} +
+ + + + {canDelete && ( + + )} +
+
+
+ ) + } + + function renderContent() { + if (activePanel === 'Gallery') { + return ( +
+
+ +
+ {cell.name} + {detail.title} · {selectedMicroscope} +
+
+
+ + +
+ {uploadedImage && ( +
+ +
+ {uploadedImage.name} + Attached source reference +
+
+ )} +
+ {galleryItems.length === 0 ? ( +

No saved views yet.

+ ) : ( + galleryItems.map((item) => { + const itemCell = findCell(allCells, item.cellId) + const itemDetail = getOrganelleDetail(item.cellId, item.organelleId, customCells) + return ( +
+ +
+ {item.title || `${itemCell.name} / ${itemDetail.title}`} + {itemCell.name} · {itemDetail.title} · {item.microscope} + {getQualityLabel({ generation: { provider: item.generationProvider, modelUrl: item.modelUrl } })} · {formatDate(item.createdAt)} +
+
+ + + + +
+
+ ) + }) + )} +
+ {galleryItems.length > 0 && ( +
+ + +
+ )} +
+ ) + } + + if (activePanel === 'Library') { + return ( +
+
+ {generatedAssets.length}generated/imported + {readyGeneratedAssets.length}ready GLB + {referenceAssets.length}references +
+ +
+
+ + + Generated & Imported Assets + + {readyGeneratedAssets.length}/{generatedAssets.length} ready +
+ {generatedAssets.length === 0 ? ( +
+ + No generated assets yet. + Upload an image or import a GLB from Asset Source. +
+ ) : ( +
+ {generatedAssets.map((item) => renderAssetCard(item))} +
+ )} +
+ +
+
+ + + Khronos Reference GLB + + material checks +
+
+ {referenceAssets.map((item) => renderAssetCard(item, { compact: true }))} +
+
+ +
+ + Starter procedural scenes + {starterAssets.length} + +
+ {starterAssets.map((item) => ( + + ))} +
+
+
+ ) + } + + if (activePanel === 'Notebooks') { + const noteEntries = Object.entries(notes) + return ( +
+