Upload 150 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +3 -0
- backend/.dockerignore +6 -6
- backend/.gitignore +33 -33
- backend/Dockerfile +29 -29
- backend/README.md +192 -192
- backend/admin.html +202 -202
- backend/core/config.py +92 -92
- backend/home.html +61 -61
- backend/logs/app.log +0 -0
- backend/main.py +128 -176
- backend/models/schemas.py +1 -3
- backend/preview.html +242 -242
- backend/requirements.txt +0 -1
- backend/routers/auth.py +7 -40
- backend/routers/catalog.py +240 -240
- backend/routers/segmentation.py +723 -760
- backend/run_server.bat +12 -12
- backend/services/gradio_client_service.py +104 -108
- backend/services/image_service.py +40 -12
- backend/services/inpainting_service.py +12 -204
- backend/services/sam2_service.py +0 -3
- backend/services/texture_service.py +851 -851
- backend/templates/classic_dashboard.html +0 -0
- backend/texturas/Texture_wpc_deck/DECK_gris.png +3 -0
- backend/texturas/Texture_wpc_deck/DECK_madera.png +3 -0
- backend/texturas/Texture_wpc_deck/DECK_madera_oscuro.png +3 -0
- backend/visualizador.html +125 -125
- frontend/.gitignore +24 -24
- frontend/FRONTEND_DOCUMENTATION.md +250 -250
- frontend/README.md +73 -73
- frontend/eslint.config.js +23 -23
- frontend/index.html +28 -35
- frontend/package.json +47 -47
- frontend/postcss.config.js +6 -6
- frontend/public/icons.svg +24 -24
- frontend/rewrite_css.py +508 -508
- frontend/scripts/generate-version.js +19 -19
- frontend/src/App.css +542 -542
- frontend/src/App.tsx +20 -20
- frontend/src/api/client.ts +100 -100
- frontend/src/assets/vite.svg +1 -1
- frontend/src/components/ui/LoadingScreen.tsx +33 -33
- frontend/src/data/roomSetupData.ts +329 -329
- frontend/src/features/roomSetup/RoomSetup.tsx +318 -334
- frontend/src/features/roomSetup/RoomSetupComponents.tsx +62 -62
- frontend/src/features/roomSetup/roomSetup.types.ts +6 -6
- frontend/src/features/roomSetup/roomSetupHooks.ts +33 -26
- frontend/src/features/roomVisualizer/MaskLayer.tsx +45 -45
- frontend/src/features/roomVisualizer/RoomPreviewPanel.tsx +329 -338
- frontend/src/features/roomVisualizer/RoomVisualizer.tsx +617 -1000
.gitattributes
CHANGED
|
@@ -172,3 +172,6 @@ backend/uploads/generated/5b6f11b554f74c5486ce33e390f8c75d.png filter=lfs diff=l
|
|
| 172 |
backend/uploads/generated/836b20819d49430a9fc94c02e8e07885.png filter=lfs diff=lfs merge=lfs -text
|
| 173 |
backend/uploads/generated/d664c83e3b614760834cdaad28769340.png filter=lfs diff=lfs merge=lfs -text
|
| 174 |
backend/uploads/generated/dbd083972cf5430998885a9f7c36ac0a.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
backend/uploads/generated/836b20819d49430a9fc94c02e8e07885.png filter=lfs diff=lfs merge=lfs -text
|
| 173 |
backend/uploads/generated/d664c83e3b614760834cdaad28769340.png filter=lfs diff=lfs merge=lfs -text
|
| 174 |
backend/uploads/generated/dbd083972cf5430998885a9f7c36ac0a.png filter=lfs diff=lfs merge=lfs -text
|
| 175 |
+
backend/texturas/Texture_wpc_deck/DECK_gris.png filter=lfs diff=lfs merge=lfs -text
|
| 176 |
+
backend/texturas/Texture_wpc_deck/DECK_madera_oscuro.png filter=lfs diff=lfs merge=lfs -text
|
| 177 |
+
backend/texturas/Texture_wpc_deck/DECK_madera.png filter=lfs diff=lfs merge=lfs -text
|
backend/.dockerignore
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
.venv/
|
| 2 |
-
__pycache__/
|
| 3 |
-
*.pyc
|
| 4 |
-
uploads/
|
| 5 |
-
frontend/node_modules/
|
| 6 |
-
frontend/dist/
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
uploads/
|
| 5 |
+
frontend/node_modules/
|
| 6 |
+
frontend/dist/
|
backend/.gitignore
CHANGED
|
@@ -1,33 +1,33 @@
|
|
| 1 |
-
# Python
|
| 2 |
-
__pycache__/
|
| 3 |
-
*.py[cod]
|
| 4 |
-
*.pyo
|
| 5 |
-
*.pyd
|
| 6 |
-
*.cover
|
| 7 |
-
*.egg-info/
|
| 8 |
-
.eggs/
|
| 9 |
-
*.egg
|
| 10 |
-
pip-log.txt
|
| 11 |
-
pip-delete-this-directory.txt
|
| 12 |
-
htmlcov/
|
| 13 |
-
.tox/
|
| 14 |
-
.coverage
|
| 15 |
-
.coverage.*
|
| 16 |
-
.cache
|
| 17 |
-
.pytest_cache/
|
| 18 |
-
.pytest_cache
|
| 19 |
-
.env
|
| 20 |
-
venv/
|
| 21 |
-
ENV/
|
| 22 |
-
env/
|
| 23 |
-
*.env
|
| 24 |
-
|
| 25 |
-
# VS Code
|
| 26 |
-
.vscode/
|
| 27 |
-
|
| 28 |
-
# Python virtual environment
|
| 29 |
-
.venv/
|
| 30 |
-
|
| 31 |
-
# OS files
|
| 32 |
-
.DS_Store
|
| 33 |
-
Thumbs.db
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
*.cover
|
| 7 |
+
*.egg-info/
|
| 8 |
+
.eggs/
|
| 9 |
+
*.egg
|
| 10 |
+
pip-log.txt
|
| 11 |
+
pip-delete-this-directory.txt
|
| 12 |
+
htmlcov/
|
| 13 |
+
.tox/
|
| 14 |
+
.coverage
|
| 15 |
+
.coverage.*
|
| 16 |
+
.cache
|
| 17 |
+
.pytest_cache/
|
| 18 |
+
.pytest_cache
|
| 19 |
+
.env
|
| 20 |
+
venv/
|
| 21 |
+
ENV/
|
| 22 |
+
env/
|
| 23 |
+
*.env
|
| 24 |
+
|
| 25 |
+
# VS Code
|
| 26 |
+
.vscode/
|
| 27 |
+
|
| 28 |
+
# Python virtual environment
|
| 29 |
+
.venv/
|
| 30 |
+
|
| 31 |
+
# OS files
|
| 32 |
+
.DS_Store
|
| 33 |
+
Thumbs.db
|
backend/Dockerfile
CHANGED
|
@@ -1,29 +1,29 @@
|
|
| 1 |
-
FROM python:3.12-slim
|
| 2 |
-
|
| 3 |
-
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
-
PYTHONUNBUFFERED=1 \
|
| 5 |
-
PIP_NO_CACHE_DIR=1 \
|
| 6 |
-
PORT=8000 \
|
| 7 |
-
HF_HUB_DISABLE_PROGRESS_BARS=0
|
| 8 |
-
|
| 9 |
-
WORKDIR /app
|
| 10 |
-
|
| 11 |
-
RUN apt-get update \
|
| 12 |
-
&& apt-get install -y --no-install-recommends \
|
| 13 |
-
git libglib2.0-0 libsm6 libxrender1 libxext6 libx11-6 libxcb1 libgl1 libgl1-mesa-dri \
|
| 14 |
-
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
-
|
| 16 |
-
COPY requirements.txt ./
|
| 17 |
-
RUN python -m pip install --upgrade pip \
|
| 18 |
-
&& python -m pip install -r requirements.txt
|
| 19 |
-
|
| 20 |
-
COPY . ./
|
| 21 |
-
|
| 22 |
-
RUN chmod +x entrypoint.sh
|
| 23 |
-
|
| 24 |
-
# /app/models is mounted as a volume so the SAM2 checkpoint persists across restarts
|
| 25 |
-
VOLUME ["/app/models"]
|
| 26 |
-
|
| 27 |
-
EXPOSE 8000
|
| 28 |
-
|
| 29 |
-
ENTRYPOINT ["./entrypoint.sh"]
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
PIP_NO_CACHE_DIR=1 \
|
| 6 |
+
PORT=8000 \
|
| 7 |
+
HF_HUB_DISABLE_PROGRESS_BARS=0
|
| 8 |
+
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
RUN apt-get update \
|
| 12 |
+
&& apt-get install -y --no-install-recommends \
|
| 13 |
+
git libglib2.0-0 libsm6 libxrender1 libxext6 libx11-6 libxcb1 libgl1 libgl1-mesa-dri \
|
| 14 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
+
|
| 16 |
+
COPY requirements.txt ./
|
| 17 |
+
RUN python -m pip install --upgrade pip \
|
| 18 |
+
&& python -m pip install -r requirements.txt
|
| 19 |
+
|
| 20 |
+
COPY . ./
|
| 21 |
+
|
| 22 |
+
RUN chmod +x entrypoint.sh
|
| 23 |
+
|
| 24 |
+
# /app/models is mounted as a volume so the SAM2 checkpoint persists across restarts
|
| 25 |
+
VOLUME ["/app/models"]
|
| 26 |
+
|
| 27 |
+
EXPOSE 8000
|
| 28 |
+
|
| 29 |
+
ENTRYPOINT ["./entrypoint.sh"]
|
backend/README.md
CHANGED
|
@@ -1,192 +1,192 @@
|
|
| 1 |
-
# Backend - PoC SaaS Iframe
|
| 2 |
-
|
| 3 |
-
Este documento explica cómo funciona el backend del proyecto, qué endpoints ofrece y cómo se integra con el frontend React.
|
| 4 |
-
|
| 5 |
-
## Propósito
|
| 6 |
-
|
| 7 |
-
El backend sirve como:
|
| 8 |
-
|
| 9 |
-
- API para autenticación con token, configuración y gestión de sesiones.
|
| 10 |
-
- servidor de archivos estáticos para el build de React en producción (`frontend/dist`).
|
| 11 |
-
- punto de entrada de administración, preview y experiencia embebida.
|
| 12 |
-
- watcher opcional que reconstruye el frontend cuando cambian los archivos fuente.
|
| 13 |
-
|
| 14 |
-
## Estructura principal
|
| 15 |
-
|
| 16 |
-
- `main.py` - servidor FastAPI principal.
|
| 17 |
-
- `requirements.txt` - dependencias Python.
|
| 18 |
-
- `run_server.bat` - helper para arrancar el backend con el virtual environment local.
|
| 19 |
-
- `.venv/` - entorno virtual Python del backend.
|
| 20 |
-
- `home.html`, `admin.html`, `preview.html` - páginas de apoyo.
|
| 21 |
-
|
| 22 |
-
## Cómo ejecutar
|
| 23 |
-
|
| 24 |
-
### Activar entorno virtual
|
| 25 |
-
|
| 26 |
-
En Windows CMD:
|
| 27 |
-
|
| 28 |
-
```cmd
|
| 29 |
-
cd /d C:\Users\alane\OneDrive\Escritorio\Prueba-PoC\backend
|
| 30 |
-
.venv\Scripts\activate
|
| 31 |
-
```
|
| 32 |
-
|
| 33 |
-
En PowerShell:
|
| 34 |
-
|
| 35 |
-
```powershell
|
| 36 |
-
cd C:\Users\alane\OneDrive\Escritorio\Prueba-PoC\backend
|
| 37 |
-
.\.venv\Scripts\Activate.ps1
|
| 38 |
-
```
|
| 39 |
-
|
| 40 |
-
### Instalar dependencias
|
| 41 |
-
|
| 42 |
-
```cmd
|
| 43 |
-
pip install -r requirements.txt
|
| 44 |
-
```
|
| 45 |
-
|
| 46 |
-
### Ejecutar servidor
|
| 47 |
-
|
| 48 |
-
```cmd
|
| 49 |
-
python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
| 50 |
-
```
|
| 51 |
-
|
| 52 |
-
O usar el helper:
|
| 53 |
-
|
| 54 |
-
```cmd
|
| 55 |
-
run_server.bat
|
| 56 |
-
```
|
| 57 |
-
|
| 58 |
-
## Comportamiento de producción
|
| 59 |
-
|
| 60 |
-
En producción, el backend sirve el build de React desde `frontend/dist` en la ruta `/app`.
|
| 61 |
-
|
| 62 |
-
- El HTML principal se carga en `/app`
|
| 63 |
-
- Los assets de Vite se sirven en `/app/assets/...`
|
| 64 |
-
- Si `frontend/dist/index.html` no existe, se devuelve un mensaje de error claro.
|
| 65 |
-
|
| 66 |
-
## Endpoints principales
|
| 67 |
-
|
| 68 |
-
### Páginas y visualización
|
| 69 |
-
|
| 70 |
-
- `GET /` - `home.html`
|
| 71 |
-
- `GET /preview` - `preview.html`
|
| 72 |
-
- `GET /admin` - `admin.html`
|
| 73 |
-
- `GET /app` - React app estática servida desde `frontend/dist`
|
| 74 |
-
- `GET /widget.js` - script JS que inyecta un iframe con el visualizador
|
| 75 |
-
- `GET /health` - estado del backend y si el frontend build está listo
|
| 76 |
-
|
| 77 |
-
### API de integración
|
| 78 |
-
|
| 79 |
-
- `POST /api/token` - genera un token temporal para `client_id`
|
| 80 |
-
- `GET /config` - devuelve datos del cliente según `client_id` o `token`
|
| 81 |
-
- `POST /session/start` - marca la sesión como activa
|
| 82 |
-
- `GET /api/keys` - lista clientes registrados
|
| 83 |
-
- `POST /api/generate-key` - genera una nueva API key de cliente
|
| 84 |
-
- `GET /api/active-sessions` - muestra sesiones activas actuales
|
| 85 |
-
|
| 86 |
-
## Lógica de token y cliente
|
| 87 |
-
|
| 88 |
-
### Validación de token
|
| 89 |
-
|
| 90 |
-
- Los tokens se guardan en memoria en `TOKENS`
|
| 91 |
-
- Cada token vence después de `TOKEN_TTL` segundos
|
| 92 |
-
- Si el token ha expirado, se devuelve `401`
|
| 93 |
-
|
| 94 |
-
### Configuración del cliente
|
| 95 |
-
|
| 96 |
-
- El diccionario `CLIENTS` contiene los clientes registrados por defecto
|
| 97 |
-
- Cada cliente tiene `nombre`, `color_primario` y `created_at`
|
| 98 |
-
- `POST /api/generate-key` agrega un nuevo cliente a `CLIENTS`
|
| 99 |
-
|
| 100 |
-
## Seguridad y headers importantes
|
| 101 |
-
|
| 102 |
-
El backend habilita:
|
| 103 |
-
|
| 104 |
-
- CORS abierto (`allow_origins=["*"]`) para facilitar el desarrollo
|
| 105 |
-
- Eliminación de `x-frame-options` en todas las respuestas
|
| 106 |
-
- Agrega `Content-Security-Policy: frame-ancestors *` para permitir iframes
|
| 107 |
-
|
| 108 |
-
## Watcher automático de frontend
|
| 109 |
-
|
| 110 |
-
El backend también incluye un watcher que observa cambios en el frontend y ejecuta `npm run build` automáticamente.
|
| 111 |
-
|
| 112 |
-
### Qué archivos vigila
|
| 113 |
-
|
| 114 |
-
- `frontend/src/**/*.{ts,tsx,js,jsx,css,json,html}`
|
| 115 |
-
- `frontend/vite.config.ts`
|
| 116 |
-
- `frontend/package.json`
|
| 117 |
-
- `frontend/tsconfig.json`
|
| 118 |
-
|
| 119 |
-
### Cómo funciona
|
| 120 |
-
|
| 121 |
-
- Si el backend está ejecutándose, el watcher corre en un hilo daemon.
|
| 122 |
-
- Cuando detecta cambios, ejecuta `npm run build` en `frontend/`.
|
| 123 |
-
- El resultado actualiza `frontend/dist` para que `/app` sirva la versión nueva.
|
| 124 |
-
|
| 125 |
-
### Requisitos del watcher
|
| 126 |
-
|
| 127 |
-
- Necesitas tener `npm` instalado y accesible desde el PATH.
|
| 128 |
-
- El backend debe arrancarse desde el directorio raíz del proyecto.
|
| 129 |
-
|
| 130 |
-
## Desarrollo y producción
|
| 131 |
-
|
| 132 |
-
### Desarrollo
|
| 133 |
-
|
| 134 |
-
- Para trabajar en el frontend con hot reload, usa el dev server de Vite:
|
| 135 |
-
|
| 136 |
-
```cmd
|
| 137 |
-
cd /d C:\Users\alane\OneDrive\Escritorio\Prueba-PoC\frontend
|
| 138 |
-
npm run dev
|
| 139 |
-
```
|
| 140 |
-
|
| 141 |
-
- Abre `http://localhost:5173/app?token=...`
|
| 142 |
-
|
| 143 |
-
### Producción / backend
|
| 144 |
-
|
| 145 |
-
- Para servir el frontend estático desde el backend:
|
| 146 |
-
|
| 147 |
-
```cmd
|
| 148 |
-
cd /d C:\Users\alane\OneDrive\Escritorio\Prueba-PoC\frontend
|
| 149 |
-
npm run build
|
| 150 |
-
```
|
| 151 |
-
|
| 152 |
-
- Luego carga `http://localhost:8000/app?token=...`
|
| 153 |
-
|
| 154 |
-
## Integración con hyper-ferreteria
|
| 155 |
-
|
| 156 |
-
Este backend puede montar la lógica de `hyper-ferreteria` en un prefijo alterno para evitar conflictos de rutas.
|
| 157 |
-
|
| 158 |
-
- Se carga si la carpeta `../hyper-ferreteria` existe junto al directorio `Prueba-PoC`.
|
| 159 |
-
- La integración queda disponible en `http://localhost:8000/hf`.
|
| 160 |
-
- Las rutas internas de `hyper-ferreteria` se exponen como:
|
| 161 |
-
- `http://localhost:8000/hf/upload_async`
|
| 162 |
-
- `http://localhost:8000/hf/segment_guided`
|
| 163 |
-
- `http://localhost:8000/hf/analyze_scene`
|
| 164 |
-
- `http://localhost:8000/hf/apply_texture`
|
| 165 |
-
- `http://localhost:8000/hf/textures`
|
| 166 |
-
- `http://localhost:8000/hf/image/{filename}`
|
| 167 |
-
- `http://localhost:8000/hf/masks/{filename}`
|
| 168 |
-
- `http://localhost:8000/hf/ai/{filename}`
|
| 169 |
-
- y otras rutas de `hyper-ferreteria` con el prefijo `/hf`
|
| 170 |
-
|
| 171 |
-
## Docker
|
| 172 |
-
|
| 173 |
-
Se añadió soporte Docker al backend con un `Dockerfile` y `.dockerignore`.
|
| 174 |
-
|
| 175 |
-
- El contenedor expone el puerto `8000`.
|
| 176 |
-
- El servicio arranca con `uvicorn main:app --host 0.0.0.0 --port 8000`.
|
| 177 |
-
- El `requirements.txt` del backend fue ampliado para incluir las dependencias de `hyper-ferreteria`.
|
| 178 |
-
|
| 179 |
-
Para construir y correr:
|
| 180 |
-
|
| 181 |
-
```cmd
|
| 182 |
-
cd /d C:\Users\alane\OneDrive\Escritorio\Trabajo\Prueba-PoC\backend
|
| 183 |
-
docker build -t prueba-poc-backend .
|
| 184 |
-
docker run -p 8000:8000 prueba-poc-backend
|
| 185 |
-
```
|
| 186 |
-
|
| 187 |
-
## Notas para nuevos desarrolladores
|
| 188 |
-
|
| 189 |
-
- El backend es el origen de verdad para la lógica de seguridad y sesiones.
|
| 190 |
-
- El frontend puede correr en modo dev para desarrollo rápido.
|
| 191 |
-
- En producción, el backend usa el build estático de React.
|
| 192 |
-
- Si un cambio en `frontend/src` no se refleja, revisa si estás usando `5173` (dev) o `8000` (backend+build).
|
|
|
|
| 1 |
+
# Backend - PoC SaaS Iframe
|
| 2 |
+
|
| 3 |
+
Este documento explica cómo funciona el backend del proyecto, qué endpoints ofrece y cómo se integra con el frontend React.
|
| 4 |
+
|
| 5 |
+
## Propósito
|
| 6 |
+
|
| 7 |
+
El backend sirve como:
|
| 8 |
+
|
| 9 |
+
- API para autenticación con token, configuración y gestión de sesiones.
|
| 10 |
+
- servidor de archivos estáticos para el build de React en producción (`frontend/dist`).
|
| 11 |
+
- punto de entrada de administración, preview y experiencia embebida.
|
| 12 |
+
- watcher opcional que reconstruye el frontend cuando cambian los archivos fuente.
|
| 13 |
+
|
| 14 |
+
## Estructura principal
|
| 15 |
+
|
| 16 |
+
- `main.py` - servidor FastAPI principal.
|
| 17 |
+
- `requirements.txt` - dependencias Python.
|
| 18 |
+
- `run_server.bat` - helper para arrancar el backend con el virtual environment local.
|
| 19 |
+
- `.venv/` - entorno virtual Python del backend.
|
| 20 |
+
- `home.html`, `admin.html`, `preview.html` - páginas de apoyo.
|
| 21 |
+
|
| 22 |
+
## Cómo ejecutar
|
| 23 |
+
|
| 24 |
+
### Activar entorno virtual
|
| 25 |
+
|
| 26 |
+
En Windows CMD:
|
| 27 |
+
|
| 28 |
+
```cmd
|
| 29 |
+
cd /d C:\Users\alane\OneDrive\Escritorio\Prueba-PoC\backend
|
| 30 |
+
.venv\Scripts\activate
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
En PowerShell:
|
| 34 |
+
|
| 35 |
+
```powershell
|
| 36 |
+
cd C:\Users\alane\OneDrive\Escritorio\Prueba-PoC\backend
|
| 37 |
+
.\.venv\Scripts\Activate.ps1
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
### Instalar dependencias
|
| 41 |
+
|
| 42 |
+
```cmd
|
| 43 |
+
pip install -r requirements.txt
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
### Ejecutar servidor
|
| 47 |
+
|
| 48 |
+
```cmd
|
| 49 |
+
python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
O usar el helper:
|
| 53 |
+
|
| 54 |
+
```cmd
|
| 55 |
+
run_server.bat
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
## Comportamiento de producción
|
| 59 |
+
|
| 60 |
+
En producción, el backend sirve el build de React desde `frontend/dist` en la ruta `/app`.
|
| 61 |
+
|
| 62 |
+
- El HTML principal se carga en `/app`
|
| 63 |
+
- Los assets de Vite se sirven en `/app/assets/...`
|
| 64 |
+
- Si `frontend/dist/index.html` no existe, se devuelve un mensaje de error claro.
|
| 65 |
+
|
| 66 |
+
## Endpoints principales
|
| 67 |
+
|
| 68 |
+
### Páginas y visualización
|
| 69 |
+
|
| 70 |
+
- `GET /` - `home.html`
|
| 71 |
+
- `GET /preview` - `preview.html`
|
| 72 |
+
- `GET /admin` - `admin.html`
|
| 73 |
+
- `GET /app` - React app estática servida desde `frontend/dist`
|
| 74 |
+
- `GET /widget.js` - script JS que inyecta un iframe con el visualizador
|
| 75 |
+
- `GET /health` - estado del backend y si el frontend build está listo
|
| 76 |
+
|
| 77 |
+
### API de integración
|
| 78 |
+
|
| 79 |
+
- `POST /api/token` - genera un token temporal para `client_id`
|
| 80 |
+
- `GET /config` - devuelve datos del cliente según `client_id` o `token`
|
| 81 |
+
- `POST /session/start` - marca la sesión como activa
|
| 82 |
+
- `GET /api/keys` - lista clientes registrados
|
| 83 |
+
- `POST /api/generate-key` - genera una nueva API key de cliente
|
| 84 |
+
- `GET /api/active-sessions` - muestra sesiones activas actuales
|
| 85 |
+
|
| 86 |
+
## Lógica de token y cliente
|
| 87 |
+
|
| 88 |
+
### Validación de token
|
| 89 |
+
|
| 90 |
+
- Los tokens se guardan en memoria en `TOKENS`
|
| 91 |
+
- Cada token vence después de `TOKEN_TTL` segundos
|
| 92 |
+
- Si el token ha expirado, se devuelve `401`
|
| 93 |
+
|
| 94 |
+
### Configuración del cliente
|
| 95 |
+
|
| 96 |
+
- El diccionario `CLIENTS` contiene los clientes registrados por defecto
|
| 97 |
+
- Cada cliente tiene `nombre`, `color_primario` y `created_at`
|
| 98 |
+
- `POST /api/generate-key` agrega un nuevo cliente a `CLIENTS`
|
| 99 |
+
|
| 100 |
+
## Seguridad y headers importantes
|
| 101 |
+
|
| 102 |
+
El backend habilita:
|
| 103 |
+
|
| 104 |
+
- CORS abierto (`allow_origins=["*"]`) para facilitar el desarrollo
|
| 105 |
+
- Eliminación de `x-frame-options` en todas las respuestas
|
| 106 |
+
- Agrega `Content-Security-Policy: frame-ancestors *` para permitir iframes
|
| 107 |
+
|
| 108 |
+
## Watcher automático de frontend
|
| 109 |
+
|
| 110 |
+
El backend también incluye un watcher que observa cambios en el frontend y ejecuta `npm run build` automáticamente.
|
| 111 |
+
|
| 112 |
+
### Qué archivos vigila
|
| 113 |
+
|
| 114 |
+
- `frontend/src/**/*.{ts,tsx,js,jsx,css,json,html}`
|
| 115 |
+
- `frontend/vite.config.ts`
|
| 116 |
+
- `frontend/package.json`
|
| 117 |
+
- `frontend/tsconfig.json`
|
| 118 |
+
|
| 119 |
+
### Cómo funciona
|
| 120 |
+
|
| 121 |
+
- Si el backend está ejecutándose, el watcher corre en un hilo daemon.
|
| 122 |
+
- Cuando detecta cambios, ejecuta `npm run build` en `frontend/`.
|
| 123 |
+
- El resultado actualiza `frontend/dist` para que `/app` sirva la versión nueva.
|
| 124 |
+
|
| 125 |
+
### Requisitos del watcher
|
| 126 |
+
|
| 127 |
+
- Necesitas tener `npm` instalado y accesible desde el PATH.
|
| 128 |
+
- El backend debe arrancarse desde el directorio raíz del proyecto.
|
| 129 |
+
|
| 130 |
+
## Desarrollo y producción
|
| 131 |
+
|
| 132 |
+
### Desarrollo
|
| 133 |
+
|
| 134 |
+
- Para trabajar en el frontend con hot reload, usa el dev server de Vite:
|
| 135 |
+
|
| 136 |
+
```cmd
|
| 137 |
+
cd /d C:\Users\alane\OneDrive\Escritorio\Prueba-PoC\frontend
|
| 138 |
+
npm run dev
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
- Abre `http://localhost:5173/app?token=...`
|
| 142 |
+
|
| 143 |
+
### Producción / backend
|
| 144 |
+
|
| 145 |
+
- Para servir el frontend estático desde el backend:
|
| 146 |
+
|
| 147 |
+
```cmd
|
| 148 |
+
cd /d C:\Users\alane\OneDrive\Escritorio\Prueba-PoC\frontend
|
| 149 |
+
npm run build
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
- Luego carga `http://localhost:8000/app?token=...`
|
| 153 |
+
|
| 154 |
+
## Integración con hyper-ferreteria
|
| 155 |
+
|
| 156 |
+
Este backend puede montar la lógica de `hyper-ferreteria` en un prefijo alterno para evitar conflictos de rutas.
|
| 157 |
+
|
| 158 |
+
- Se carga si la carpeta `../hyper-ferreteria` existe junto al directorio `Prueba-PoC`.
|
| 159 |
+
- La integración queda disponible en `http://localhost:8000/hf`.
|
| 160 |
+
- Las rutas internas de `hyper-ferreteria` se exponen como:
|
| 161 |
+
- `http://localhost:8000/hf/upload_async`
|
| 162 |
+
- `http://localhost:8000/hf/segment_guided`
|
| 163 |
+
- `http://localhost:8000/hf/analyze_scene`
|
| 164 |
+
- `http://localhost:8000/hf/apply_texture`
|
| 165 |
+
- `http://localhost:8000/hf/textures`
|
| 166 |
+
- `http://localhost:8000/hf/image/{filename}`
|
| 167 |
+
- `http://localhost:8000/hf/masks/{filename}`
|
| 168 |
+
- `http://localhost:8000/hf/ai/{filename}`
|
| 169 |
+
- y otras rutas de `hyper-ferreteria` con el prefijo `/hf`
|
| 170 |
+
|
| 171 |
+
## Docker
|
| 172 |
+
|
| 173 |
+
Se añadió soporte Docker al backend con un `Dockerfile` y `.dockerignore`.
|
| 174 |
+
|
| 175 |
+
- El contenedor expone el puerto `8000`.
|
| 176 |
+
- El servicio arranca con `uvicorn main:app --host 0.0.0.0 --port 8000`.
|
| 177 |
+
- El `requirements.txt` del backend fue ampliado para incluir las dependencias de `hyper-ferreteria`.
|
| 178 |
+
|
| 179 |
+
Para construir y correr:
|
| 180 |
+
|
| 181 |
+
```cmd
|
| 182 |
+
cd /d C:\Users\alane\OneDrive\Escritorio\Trabajo\Prueba-PoC\backend
|
| 183 |
+
docker build -t prueba-poc-backend .
|
| 184 |
+
docker run -p 8000:8000 prueba-poc-backend
|
| 185 |
+
```
|
| 186 |
+
|
| 187 |
+
## Notas para nuevos desarrolladores
|
| 188 |
+
|
| 189 |
+
- El backend es el origen de verdad para la lógica de seguridad y sesiones.
|
| 190 |
+
- El frontend puede correr en modo dev para desarrollo rápido.
|
| 191 |
+
- En producción, el backend usa el build estático de React.
|
| 192 |
+
- Si un cambio en `frontend/src` no se refleja, revisa si estás usando `5173` (dev) o `8000` (backend+build).
|
backend/admin.html
CHANGED
|
@@ -1,202 +1,202 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html lang="es">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8" />
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
-
<title>Panel de Control SaaS</title>
|
| 7 |
-
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
-
</head>
|
| 9 |
-
<body class="bg-slate-100 text-slate-900">
|
| 10 |
-
<div class="max-w-6xl mx-auto p-6 space-y-6">
|
| 11 |
-
<header
|
| 12 |
-
class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"
|
| 13 |
-
>
|
| 14 |
-
<div>
|
| 15 |
-
<h1 class="text-3xl font-bold">Panel de Control SaaS</h1>
|
| 16 |
-
<p class="text-slate-600">
|
| 17 |
-
Desde aquí puedes ver usuarios activos, generar nuevas API keys y
|
| 18 |
-
obtener el script de integración.
|
| 19 |
-
</p>
|
| 20 |
-
</div>
|
| 21 |
-
<div class="space-x-2">
|
| 22 |
-
<a
|
| 23 |
-
href="/preview"
|
| 24 |
-
class="inline-block px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
| 25 |
-
>Vista previa</a
|
| 26 |
-
>
|
| 27 |
-
<button
|
| 28 |
-
id="open-app-button"
|
| 29 |
-
class="inline-block px-4 py-2 bg-slate-200 text-slate-900 rounded-lg hover:bg-slate-300"
|
| 30 |
-
>
|
| 31 |
-
Abrir app
|
| 32 |
-
</button>
|
| 33 |
-
</div>
|
| 34 |
-
</header>
|
| 35 |
-
|
| 36 |
-
<section class="grid gap-6 lg:grid-cols-2">
|
| 37 |
-
<div class="bg-white rounded-3xl p-6 shadow-lg">
|
| 38 |
-
<h2 class="text-xl font-semibold mb-4">Generar nueva API key</h2>
|
| 39 |
-
<div class="space-y-4">
|
| 40 |
-
<label class="block">
|
| 41 |
-
<span class="text-sm font-medium text-slate-700"
|
| 42 |
-
>Nombre del cliente</span
|
| 43 |
-
>
|
| 44 |
-
<input
|
| 45 |
-
id="client-name"
|
| 46 |
-
type="text"
|
| 47 |
-
placeholder="Ej. Tienda Sur"
|
| 48 |
-
class="mt-2 w-full rounded-2xl border border-slate-300 px-4 py-3 focus:border-blue-500 focus:outline-none"
|
| 49 |
-
/>
|
| 50 |
-
</label>
|
| 51 |
-
<button
|
| 52 |
-
id="generate-key"
|
| 53 |
-
class="w-full px-5 py-3 bg-green-600 text-white rounded-2xl hover:bg-green-700"
|
| 54 |
-
>
|
| 55 |
-
Generar API key
|
| 56 |
-
</button>
|
| 57 |
-
<div
|
| 58 |
-
id="generate-result"
|
| 59 |
-
class="hidden rounded-2xl border border-slate-200 bg-slate-50 p-4 text-slate-900"
|
| 60 |
-
></div>
|
| 61 |
-
</div>
|
| 62 |
-
</div>
|
| 63 |
-
|
| 64 |
-
<div class="bg-white rounded-3xl p-6 shadow-lg">
|
| 65 |
-
<h2 class="text-xl font-semibold mb-4">
|
| 66 |
-
Instrucciones de integración
|
| 67 |
-
</h2>
|
| 68 |
-
<p class="text-slate-600 mb-4">
|
| 69 |
-
El desarrollador debe insertar un contenedor y el script del widget
|
| 70 |
-
en su HTML:
|
| 71 |
-
</p>
|
| 72 |
-
<pre
|
| 73 |
-
class="rounded-2xl bg-slate-950 p-4 text-slate-100 overflow-x-auto"
|
| 74 |
-
><code id="static-snippet"></code></pre>
|
| 75 |
-
<script>
|
| 76 |
-
document.getElementById("static-snippet").textContent =
|
| 77 |
-
'<div id="contenedor-saas" data-client-id="ID_UNICO_DEL_CLIENTE_001"></div>\n' +
|
| 78 |
-
'<script src="' + window.location.origin + '/widget.js"><\/script>';
|
| 79 |
-
</script>
|
| 80 |
-
<p class="text-sm text-slate-500 mt-4">
|
| 81 |
-
Reemplaza <strong>ID_UNICO_DEL_CLIENTE_001</strong> por la API key
|
| 82 |
-
generada.
|
| 83 |
-
</p>
|
| 84 |
-
</div>
|
| 85 |
-
</section>
|
| 86 |
-
|
| 87 |
-
<section class="grid gap-6 lg:grid-cols-2">
|
| 88 |
-
<div class="bg-white rounded-3xl p-6 shadow-lg">
|
| 89 |
-
<h2 class="text-xl font-semibold mb-4">Clientes registrados</h2>
|
| 90 |
-
<div id="clients-list" class="space-y-3 text-slate-700"></div>
|
| 91 |
-
</div>
|
| 92 |
-
<div class="bg-white rounded-3xl p-6 shadow-lg">
|
| 93 |
-
<h2 class="text-xl font-semibold mb-4">Usuarios activos</h2>
|
| 94 |
-
<div id="active-list" class="space-y-3 text-slate-700"></div>
|
| 95 |
-
</div>
|
| 96 |
-
</section>
|
| 97 |
-
</div>
|
| 98 |
-
|
| 99 |
-
<script>
|
| 100 |
-
async function loadData() {
|
| 101 |
-
const [keysRes, activeRes] = await Promise.all([
|
| 102 |
-
fetch("/api/keys"),
|
| 103 |
-
fetch("/api/active-sessions"),
|
| 104 |
-
]);
|
| 105 |
-
const keysData = await keysRes.json();
|
| 106 |
-
const activeData = await activeRes.json();
|
| 107 |
-
|
| 108 |
-
const clientsList = document.getElementById("clients-list");
|
| 109 |
-
clientsList.innerHTML = keysData.keys
|
| 110 |
-
.map(
|
| 111 |
-
(key) => `
|
| 112 |
-
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
| 113 |
-
<div class="font-semibold">${key.nombre}</div>
|
| 114 |
-
<div class="text-sm text-slate-500">API key: <code>${key.client_id}</code></div>
|
| 115 |
-
<div class="text-sm text-slate-500">Color primario: ${key.color_primario}</div>
|
| 116 |
-
<div class="text-sm text-slate-500">Creado: ${key.created_at}</div>
|
| 117 |
-
</div>
|
| 118 |
-
`,
|
| 119 |
-
)
|
| 120 |
-
.join("");
|
| 121 |
-
|
| 122 |
-
const activeList = document.getElementById("active-list");
|
| 123 |
-
if (activeData.active_sessions.length === 0) {
|
| 124 |
-
activeList.innerHTML =
|
| 125 |
-
'<p class="text-sm text-slate-500">No hay sesiones activas aún.</p>';
|
| 126 |
-
} else {
|
| 127 |
-
activeList.innerHTML = activeData.active_sessions
|
| 128 |
-
.map(
|
| 129 |
-
(item) => `
|
| 130 |
-
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
| 131 |
-
<div class="font-semibold">${item.client_id}</div>
|
| 132 |
-
<div class="text-sm text-slate-500">Último acceso: ${item.last_seen}</div>
|
| 133 |
-
</div>
|
| 134 |
-
`,
|
| 135 |
-
)
|
| 136 |
-
.join("");
|
| 137 |
-
}
|
| 138 |
-
}
|
| 139 |
-
|
| 140 |
-
document
|
| 141 |
-
.getElementById("generate-key")
|
| 142 |
-
.addEventListener("click", async () => {
|
| 143 |
-
const nameInput = document.getElementById("client-name");
|
| 144 |
-
const name = nameInput.value.trim();
|
| 145 |
-
if (!name) {
|
| 146 |
-
alert("Ingresa el nombre del cliente antes de generar la API key.");
|
| 147 |
-
return;
|
| 148 |
-
}
|
| 149 |
-
|
| 150 |
-
const formData = new URLSearchParams();
|
| 151 |
-
formData.append("nombre", name);
|
| 152 |
-
|
| 153 |
-
const response = await fetch("/api/generate-key", {
|
| 154 |
-
method: "POST",
|
| 155 |
-
body: formData,
|
| 156 |
-
});
|
| 157 |
-
const result = await response.json();
|
| 158 |
-
|
| 159 |
-
const resultBox = document.getElementById("generate-result");
|
| 160 |
-
resultBox.classList.remove("hidden");
|
| 161 |
-
const escapedSnippet = result.snippet
|
| 162 |
-
.replace(/&/g, "&")
|
| 163 |
-
.replace(/</g, "<")
|
| 164 |
-
.replace(/>/g, ">");
|
| 165 |
-
resultBox.innerHTML = `
|
| 166 |
-
<p class="font-semibold text-slate-900">API key generada:</p>
|
| 167 |
-
<p class="mt-2"><code>${result.client_id}</code></p>
|
| 168 |
-
<p class="mt-3 font-semibold text-slate-900">Snippet para el cliente:</p>
|
| 169 |
-
<pre class="mt-2 rounded-2xl bg-slate-950 p-4 text-slate-100 overflow-x-auto"><code>${escapedSnippet}</code></pre>
|
| 170 |
-
`;
|
| 171 |
-
|
| 172 |
-
nameInput.value = "";
|
| 173 |
-
await loadData();
|
| 174 |
-
});
|
| 175 |
-
|
| 176 |
-
loadData();
|
| 177 |
-
|
| 178 |
-
document
|
| 179 |
-
.getElementById("open-app-button")
|
| 180 |
-
.addEventListener("click", async () => {
|
| 181 |
-
const clientId = "ID_UNICO_DEL_CLIENTE_001";
|
| 182 |
-
try {
|
| 183 |
-
const res = await fetch("/api/token", {
|
| 184 |
-
method: "POST",
|
| 185 |
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
| 186 |
-
body: `client_id=${encodeURIComponent(clientId)}`,
|
| 187 |
-
});
|
| 188 |
-
const data = await res.json();
|
| 189 |
-
if (!res.ok) {
|
| 190 |
-
throw new Error(data.error || "No se pudo generar token.");
|
| 191 |
-
}
|
| 192 |
-
window.open(
|
| 193 |
-
`/app?token=${encodeURIComponent(data.token)}`,
|
| 194 |
-
"_blank",
|
| 195 |
-
);
|
| 196 |
-
} catch (error) {
|
| 197 |
-
alert("No se pudo abrir el app: " + error.message);
|
| 198 |
-
}
|
| 199 |
-
});
|
| 200 |
-
</script>
|
| 201 |
-
</body>
|
| 202 |
-
</html>
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="es">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Panel de Control SaaS</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
</head>
|
| 9 |
+
<body class="bg-slate-100 text-slate-900">
|
| 10 |
+
<div class="max-w-6xl mx-auto p-6 space-y-6">
|
| 11 |
+
<header
|
| 12 |
+
class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"
|
| 13 |
+
>
|
| 14 |
+
<div>
|
| 15 |
+
<h1 class="text-3xl font-bold">Panel de Control SaaS</h1>
|
| 16 |
+
<p class="text-slate-600">
|
| 17 |
+
Desde aquí puedes ver usuarios activos, generar nuevas API keys y
|
| 18 |
+
obtener el script de integración.
|
| 19 |
+
</p>
|
| 20 |
+
</div>
|
| 21 |
+
<div class="space-x-2">
|
| 22 |
+
<a
|
| 23 |
+
href="/preview"
|
| 24 |
+
class="inline-block px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
| 25 |
+
>Vista previa</a
|
| 26 |
+
>
|
| 27 |
+
<button
|
| 28 |
+
id="open-app-button"
|
| 29 |
+
class="inline-block px-4 py-2 bg-slate-200 text-slate-900 rounded-lg hover:bg-slate-300"
|
| 30 |
+
>
|
| 31 |
+
Abrir app
|
| 32 |
+
</button>
|
| 33 |
+
</div>
|
| 34 |
+
</header>
|
| 35 |
+
|
| 36 |
+
<section class="grid gap-6 lg:grid-cols-2">
|
| 37 |
+
<div class="bg-white rounded-3xl p-6 shadow-lg">
|
| 38 |
+
<h2 class="text-xl font-semibold mb-4">Generar nueva API key</h2>
|
| 39 |
+
<div class="space-y-4">
|
| 40 |
+
<label class="block">
|
| 41 |
+
<span class="text-sm font-medium text-slate-700"
|
| 42 |
+
>Nombre del cliente</span
|
| 43 |
+
>
|
| 44 |
+
<input
|
| 45 |
+
id="client-name"
|
| 46 |
+
type="text"
|
| 47 |
+
placeholder="Ej. Tienda Sur"
|
| 48 |
+
class="mt-2 w-full rounded-2xl border border-slate-300 px-4 py-3 focus:border-blue-500 focus:outline-none"
|
| 49 |
+
/>
|
| 50 |
+
</label>
|
| 51 |
+
<button
|
| 52 |
+
id="generate-key"
|
| 53 |
+
class="w-full px-5 py-3 bg-green-600 text-white rounded-2xl hover:bg-green-700"
|
| 54 |
+
>
|
| 55 |
+
Generar API key
|
| 56 |
+
</button>
|
| 57 |
+
<div
|
| 58 |
+
id="generate-result"
|
| 59 |
+
class="hidden rounded-2xl border border-slate-200 bg-slate-50 p-4 text-slate-900"
|
| 60 |
+
></div>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<div class="bg-white rounded-3xl p-6 shadow-lg">
|
| 65 |
+
<h2 class="text-xl font-semibold mb-4">
|
| 66 |
+
Instrucciones de integración
|
| 67 |
+
</h2>
|
| 68 |
+
<p class="text-slate-600 mb-4">
|
| 69 |
+
El desarrollador debe insertar un contenedor y el script del widget
|
| 70 |
+
en su HTML:
|
| 71 |
+
</p>
|
| 72 |
+
<pre
|
| 73 |
+
class="rounded-2xl bg-slate-950 p-4 text-slate-100 overflow-x-auto"
|
| 74 |
+
><code id="static-snippet"></code></pre>
|
| 75 |
+
<script>
|
| 76 |
+
document.getElementById("static-snippet").textContent =
|
| 77 |
+
'<div id="contenedor-saas" data-client-id="ID_UNICO_DEL_CLIENTE_001"></div>\n' +
|
| 78 |
+
'<script src="' + window.location.origin + '/widget.js"><\/script>';
|
| 79 |
+
</script>
|
| 80 |
+
<p class="text-sm text-slate-500 mt-4">
|
| 81 |
+
Reemplaza <strong>ID_UNICO_DEL_CLIENTE_001</strong> por la API key
|
| 82 |
+
generada.
|
| 83 |
+
</p>
|
| 84 |
+
</div>
|
| 85 |
+
</section>
|
| 86 |
+
|
| 87 |
+
<section class="grid gap-6 lg:grid-cols-2">
|
| 88 |
+
<div class="bg-white rounded-3xl p-6 shadow-lg">
|
| 89 |
+
<h2 class="text-xl font-semibold mb-4">Clientes registrados</h2>
|
| 90 |
+
<div id="clients-list" class="space-y-3 text-slate-700"></div>
|
| 91 |
+
</div>
|
| 92 |
+
<div class="bg-white rounded-3xl p-6 shadow-lg">
|
| 93 |
+
<h2 class="text-xl font-semibold mb-4">Usuarios activos</h2>
|
| 94 |
+
<div id="active-list" class="space-y-3 text-slate-700"></div>
|
| 95 |
+
</div>
|
| 96 |
+
</section>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
<script>
|
| 100 |
+
async function loadData() {
|
| 101 |
+
const [keysRes, activeRes] = await Promise.all([
|
| 102 |
+
fetch("/api/keys"),
|
| 103 |
+
fetch("/api/active-sessions"),
|
| 104 |
+
]);
|
| 105 |
+
const keysData = await keysRes.json();
|
| 106 |
+
const activeData = await activeRes.json();
|
| 107 |
+
|
| 108 |
+
const clientsList = document.getElementById("clients-list");
|
| 109 |
+
clientsList.innerHTML = keysData.keys
|
| 110 |
+
.map(
|
| 111 |
+
(key) => `
|
| 112 |
+
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
| 113 |
+
<div class="font-semibold">${key.nombre}</div>
|
| 114 |
+
<div class="text-sm text-slate-500">API key: <code>${key.client_id}</code></div>
|
| 115 |
+
<div class="text-sm text-slate-500">Color primario: ${key.color_primario}</div>
|
| 116 |
+
<div class="text-sm text-slate-500">Creado: ${key.created_at}</div>
|
| 117 |
+
</div>
|
| 118 |
+
`,
|
| 119 |
+
)
|
| 120 |
+
.join("");
|
| 121 |
+
|
| 122 |
+
const activeList = document.getElementById("active-list");
|
| 123 |
+
if (activeData.active_sessions.length === 0) {
|
| 124 |
+
activeList.innerHTML =
|
| 125 |
+
'<p class="text-sm text-slate-500">No hay sesiones activas aún.</p>';
|
| 126 |
+
} else {
|
| 127 |
+
activeList.innerHTML = activeData.active_sessions
|
| 128 |
+
.map(
|
| 129 |
+
(item) => `
|
| 130 |
+
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
| 131 |
+
<div class="font-semibold">${item.client_id}</div>
|
| 132 |
+
<div class="text-sm text-slate-500">Último acceso: ${item.last_seen}</div>
|
| 133 |
+
</div>
|
| 134 |
+
`,
|
| 135 |
+
)
|
| 136 |
+
.join("");
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
document
|
| 141 |
+
.getElementById("generate-key")
|
| 142 |
+
.addEventListener("click", async () => {
|
| 143 |
+
const nameInput = document.getElementById("client-name");
|
| 144 |
+
const name = nameInput.value.trim();
|
| 145 |
+
if (!name) {
|
| 146 |
+
alert("Ingresa el nombre del cliente antes de generar la API key.");
|
| 147 |
+
return;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
const formData = new URLSearchParams();
|
| 151 |
+
formData.append("nombre", name);
|
| 152 |
+
|
| 153 |
+
const response = await fetch("/api/generate-key", {
|
| 154 |
+
method: "POST",
|
| 155 |
+
body: formData,
|
| 156 |
+
});
|
| 157 |
+
const result = await response.json();
|
| 158 |
+
|
| 159 |
+
const resultBox = document.getElementById("generate-result");
|
| 160 |
+
resultBox.classList.remove("hidden");
|
| 161 |
+
const escapedSnippet = result.snippet
|
| 162 |
+
.replace(/&/g, "&")
|
| 163 |
+
.replace(/</g, "<")
|
| 164 |
+
.replace(/>/g, ">");
|
| 165 |
+
resultBox.innerHTML = `
|
| 166 |
+
<p class="font-semibold text-slate-900">API key generada:</p>
|
| 167 |
+
<p class="mt-2"><code>${result.client_id}</code></p>
|
| 168 |
+
<p class="mt-3 font-semibold text-slate-900">Snippet para el cliente:</p>
|
| 169 |
+
<pre class="mt-2 rounded-2xl bg-slate-950 p-4 text-slate-100 overflow-x-auto"><code>${escapedSnippet}</code></pre>
|
| 170 |
+
`;
|
| 171 |
+
|
| 172 |
+
nameInput.value = "";
|
| 173 |
+
await loadData();
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
loadData();
|
| 177 |
+
|
| 178 |
+
document
|
| 179 |
+
.getElementById("open-app-button")
|
| 180 |
+
.addEventListener("click", async () => {
|
| 181 |
+
const clientId = "ID_UNICO_DEL_CLIENTE_001";
|
| 182 |
+
try {
|
| 183 |
+
const res = await fetch("/api/token", {
|
| 184 |
+
method: "POST",
|
| 185 |
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
| 186 |
+
body: `client_id=${encodeURIComponent(clientId)}`,
|
| 187 |
+
});
|
| 188 |
+
const data = await res.json();
|
| 189 |
+
if (!res.ok) {
|
| 190 |
+
throw new Error(data.error || "No se pudo generar token.");
|
| 191 |
+
}
|
| 192 |
+
window.open(
|
| 193 |
+
`/app?token=${encodeURIComponent(data.token)}`,
|
| 194 |
+
"_blank",
|
| 195 |
+
);
|
| 196 |
+
} catch (error) {
|
| 197 |
+
alert("No se pudo abrir el app: " + error.message);
|
| 198 |
+
}
|
| 199 |
+
});
|
| 200 |
+
</script>
|
| 201 |
+
</body>
|
| 202 |
+
</html>
|
backend/core/config.py
CHANGED
|
@@ -1,92 +1,92 @@
|
|
| 1 |
-
import logging
|
| 2 |
-
import os
|
| 3 |
-
import time
|
| 4 |
-
from datetime import datetime, timezone
|
| 5 |
-
from pathlib import Path
|
| 6 |
-
|
| 7 |
-
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 8 |
-
|
| 9 |
-
UPLOAD_DIR = BASE_DIR / "uploads"
|
| 10 |
-
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
| 11 |
-
|
| 12 |
-
VIDEO_UPLOAD_DIR = UPLOAD_DIR / "videos"
|
| 13 |
-
VIDEO_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
| 14 |
-
|
| 15 |
-
OUTPUT_DIR = BASE_DIR / "outputs"
|
| 16 |
-
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 17 |
-
|
| 18 |
-
VIDEO_OUTPUT_DIR = OUTPUT_DIR / "videos"
|
| 19 |
-
VIDEO_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 20 |
-
|
| 21 |
-
TEXTURE_DIR = BASE_DIR / "texturas"
|
| 22 |
-
TEXTURE_DIR.mkdir(parents=True, exist_ok=True)
|
| 23 |
-
|
| 24 |
-
LOG_DIR = BASE_DIR / "logs"
|
| 25 |
-
LOG_DIR.mkdir(exist_ok=True)
|
| 26 |
-
|
| 27 |
-
TEMPLATES_DIR = BASE_DIR / "templates"
|
| 28 |
-
TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
|
| 29 |
-
|
| 30 |
-
CLASSIC_DASHBOARD_HTML_PATH = TEMPLATES_DIR / "classic_dashboard.html"
|
| 31 |
-
|
| 32 |
-
logging.basicConfig(
|
| 33 |
-
level=logging.INFO,
|
| 34 |
-
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
| 35 |
-
handlers=[
|
| 36 |
-
logging.FileHandler(LOG_DIR / "app.log", encoding="utf-8"),
|
| 37 |
-
logging.StreamHandler(),
|
| 38 |
-
],
|
| 39 |
-
)
|
| 40 |
-
logger = logging.getLogger("backend.segmentation")
|
| 41 |
-
|
| 42 |
-
SAM2_CONFIG_PATH = os.getenv("SAM2_CONFIG_PATH", "configs/sam2.1/sam2.1_hiera_l.yaml")
|
| 43 |
-
SAM2_MODEL_PATH = os.getenv("SAM2_MODEL_PATH")
|
| 44 |
-
SAM2_DEFAULT_MODEL_NAMES = (
|
| 45 |
-
"sam2.1_hiera_large_fresh.pt",
|
| 46 |
-
"sam2.1_hiera_large.pt",
|
| 47 |
-
"sam2.1_hiera_large.pth",
|
| 48 |
-
"sam2_hiera_large.pt",
|
| 49 |
-
)
|
| 50 |
-
SAM2_MODEL_DIR_CANDIDATES = ("models", "modelo")
|
| 51 |
-
SAM2_UNLOAD_AFTER_USE = str(os.getenv("SAM2_UNLOAD_AFTER_USE", "0")).strip().lower() in {"1", "true", "yes"}
|
| 52 |
-
FRONTEND_DEBUG = str(os.getenv("FRONTEND_DEBUG", "0")).strip().lower() in {"1", "true", "yes", "on"}
|
| 53 |
-
|
| 54 |
-
# URL del Space de Gradio GPU (principal). Si falla, se usa el CPU fallback.
|
| 55 |
-
# Local: http://localhost:7860
|
| 56 |
-
# Producción: https://<tu-space>.hf.space
|
| 57 |
-
GRADIO_SPACE_URL: str = os.getenv("GRADIO_SPACE_URL", "").rstrip("/")
|
| 58 |
-
|
| 59 |
-
# URL del Space de Gradio CPU (respaldo automático si el GPU falla o agota quota).
|
| 60 |
-
GRADIO_CPU_FALLBACK_URL: str = os.getenv("GRADIO_CPU_FALLBACK_URL", "").rstrip("/")
|
| 61 |
-
|
| 62 |
-
MAX_UPLOAD_WIDTH = 1024
|
| 63 |
-
UPLOAD_JPEG_QUALITY = 82
|
| 64 |
-
SD_JOB_STALE_SECONDS = 120
|
| 65 |
-
UPLOAD_JOB_STALE_SECONDS = 900
|
| 66 |
-
UPLOAD_BASE_SECONDS = 8.0
|
| 67 |
-
UPLOAD_SECONDS_PER_MEGAPIXEL = 70.0
|
| 68 |
-
SD_QUICK_TIMEOUT_SECONDS = 15.0
|
| 69 |
-
|
| 70 |
-
SEMANTIC_MODEL_ID = "nvidia/segformer-b5-finetuned-ade-640-640"
|
| 71 |
-
DEPTH_MODEL_ID = "Intel/dpt-hybrid-midas"
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
def utc_now_iso() -> str:
|
| 75 |
-
return datetime.now(timezone.utc).isoformat()
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
def log_timing_start(step_name: str) -> float:
|
| 79 |
-
started = time.perf_counter()
|
| 80 |
-
logger.info(f"[{step_name}] START at {utc_now_iso()}")
|
| 81 |
-
return started
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
def log_timing_end(step_name: str, started: float) -> None:
|
| 85 |
-
elapsed = time.perf_counter() - started
|
| 86 |
-
logger.info(f"[{step_name}] DONE {elapsed:.3f}s at {utc_now_iso()}")
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def load_classic_dashboard_html() -> str:
|
| 90 |
-
if not CLASSIC_DASHBOARD_HTML_PATH.exists() or not CLASSIC_DASHBOARD_HTML_PATH.is_file():
|
| 91 |
-
raise RuntimeError(f"Dashboard HTML template not found: {CLASSIC_DASHBOARD_HTML_PATH}")
|
| 92 |
-
return CLASSIC_DASHBOARD_HTML_PATH.read_text(encoding="utf-8")
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import os
|
| 3 |
+
import time
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 8 |
+
|
| 9 |
+
UPLOAD_DIR = BASE_DIR / "uploads"
|
| 10 |
+
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
| 11 |
+
|
| 12 |
+
VIDEO_UPLOAD_DIR = UPLOAD_DIR / "videos"
|
| 13 |
+
VIDEO_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
| 14 |
+
|
| 15 |
+
OUTPUT_DIR = BASE_DIR / "outputs"
|
| 16 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 17 |
+
|
| 18 |
+
VIDEO_OUTPUT_DIR = OUTPUT_DIR / "videos"
|
| 19 |
+
VIDEO_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 20 |
+
|
| 21 |
+
TEXTURE_DIR = BASE_DIR / "texturas"
|
| 22 |
+
TEXTURE_DIR.mkdir(parents=True, exist_ok=True)
|
| 23 |
+
|
| 24 |
+
LOG_DIR = BASE_DIR / "logs"
|
| 25 |
+
LOG_DIR.mkdir(exist_ok=True)
|
| 26 |
+
|
| 27 |
+
TEMPLATES_DIR = BASE_DIR / "templates"
|
| 28 |
+
TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
|
| 29 |
+
|
| 30 |
+
CLASSIC_DASHBOARD_HTML_PATH = TEMPLATES_DIR / "classic_dashboard.html"
|
| 31 |
+
|
| 32 |
+
logging.basicConfig(
|
| 33 |
+
level=logging.INFO,
|
| 34 |
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
| 35 |
+
handlers=[
|
| 36 |
+
logging.FileHandler(LOG_DIR / "app.log", encoding="utf-8"),
|
| 37 |
+
logging.StreamHandler(),
|
| 38 |
+
],
|
| 39 |
+
)
|
| 40 |
+
logger = logging.getLogger("backend.segmentation")
|
| 41 |
+
|
| 42 |
+
SAM2_CONFIG_PATH = os.getenv("SAM2_CONFIG_PATH", "configs/sam2.1/sam2.1_hiera_l.yaml")
|
| 43 |
+
SAM2_MODEL_PATH = os.getenv("SAM2_MODEL_PATH")
|
| 44 |
+
SAM2_DEFAULT_MODEL_NAMES = (
|
| 45 |
+
"sam2.1_hiera_large_fresh.pt",
|
| 46 |
+
"sam2.1_hiera_large.pt",
|
| 47 |
+
"sam2.1_hiera_large.pth",
|
| 48 |
+
"sam2_hiera_large.pt",
|
| 49 |
+
)
|
| 50 |
+
SAM2_MODEL_DIR_CANDIDATES = ("models", "modelo")
|
| 51 |
+
SAM2_UNLOAD_AFTER_USE = str(os.getenv("SAM2_UNLOAD_AFTER_USE", "0")).strip().lower() in {"1", "true", "yes"}
|
| 52 |
+
FRONTEND_DEBUG = str(os.getenv("FRONTEND_DEBUG", "0")).strip().lower() in {"1", "true", "yes", "on"}
|
| 53 |
+
|
| 54 |
+
# URL del Space de Gradio GPU (principal). Si falla, se usa el CPU fallback.
|
| 55 |
+
# Local: http://localhost:7860
|
| 56 |
+
# Producción: https://<tu-space>.hf.space
|
| 57 |
+
GRADIO_SPACE_URL: str = os.getenv("GRADIO_SPACE_URL", "").rstrip("/")
|
| 58 |
+
|
| 59 |
+
# URL del Space de Gradio CPU (respaldo automático si el GPU falla o agota quota).
|
| 60 |
+
GRADIO_CPU_FALLBACK_URL: str = os.getenv("GRADIO_CPU_FALLBACK_URL", "").rstrip("/")
|
| 61 |
+
|
| 62 |
+
MAX_UPLOAD_WIDTH = 1024
|
| 63 |
+
UPLOAD_JPEG_QUALITY = 82
|
| 64 |
+
SD_JOB_STALE_SECONDS = 120
|
| 65 |
+
UPLOAD_JOB_STALE_SECONDS = 900
|
| 66 |
+
UPLOAD_BASE_SECONDS = 8.0
|
| 67 |
+
UPLOAD_SECONDS_PER_MEGAPIXEL = 70.0
|
| 68 |
+
SD_QUICK_TIMEOUT_SECONDS = 15.0
|
| 69 |
+
|
| 70 |
+
SEMANTIC_MODEL_ID = "nvidia/segformer-b5-finetuned-ade-640-640"
|
| 71 |
+
DEPTH_MODEL_ID = "Intel/dpt-hybrid-midas"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def utc_now_iso() -> str:
|
| 75 |
+
return datetime.now(timezone.utc).isoformat()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def log_timing_start(step_name: str) -> float:
|
| 79 |
+
started = time.perf_counter()
|
| 80 |
+
logger.info(f"[{step_name}] START at {utc_now_iso()}")
|
| 81 |
+
return started
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def log_timing_end(step_name: str, started: float) -> None:
|
| 85 |
+
elapsed = time.perf_counter() - started
|
| 86 |
+
logger.info(f"[{step_name}] DONE {elapsed:.3f}s at {utc_now_iso()}")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def load_classic_dashboard_html() -> str:
|
| 90 |
+
if not CLASSIC_DASHBOARD_HTML_PATH.exists() or not CLASSIC_DASHBOARD_HTML_PATH.is_file():
|
| 91 |
+
raise RuntimeError(f"Dashboard HTML template not found: {CLASSIC_DASHBOARD_HTML_PATH}")
|
| 92 |
+
return CLASSIC_DASHBOARD_HTML_PATH.read_text(encoding="utf-8")
|
backend/home.html
CHANGED
|
@@ -1,61 +1,61 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html lang="es">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8" />
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
-
<title>Home - SaaS Dev</title>
|
| 7 |
-
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
-
</head>
|
| 9 |
-
<body class="bg-slate-100 text-slate-900">
|
| 10 |
-
<div class="max-w-5xl mx-auto p-6 space-y-6">
|
| 11 |
-
<header class="rounded-3xl bg-white p-6 shadow-lg">
|
| 12 |
-
<h1 class="text-3xl font-bold">Home de desarrollo</h1>
|
| 13 |
-
<p class="mt-2 text-slate-600">
|
| 14 |
-
Esta página es la landing principal para el equipo de desarrollo.
|
| 15 |
-
Desde aquí puedes acceder al panel administrador, la vista previa y al
|
| 16 |
-
app embebido.
|
| 17 |
-
</p>
|
| 18 |
-
</header>
|
| 19 |
-
|
| 20 |
-
<div class="grid gap-6 md:grid-cols-3">
|
| 21 |
-
<a
|
| 22 |
-
href="/admin"
|
| 23 |
-
class="block rounded-3xl bg-white p-6 shadow-lg hover:shadow-xl transition"
|
| 24 |
-
>
|
| 25 |
-
<h2 class="text-xl font-semibold">Panel de Control</h2>
|
| 26 |
-
<p class="mt-2 text-slate-600">
|
| 27 |
-
Generar API keys, ver clientes y sesiones activas.
|
| 28 |
-
</p>
|
| 29 |
-
</a>
|
| 30 |
-
<a
|
| 31 |
-
href="/preview"
|
| 32 |
-
class="block rounded-3xl bg-white p-6 shadow-lg hover:shadow-xl transition"
|
| 33 |
-
>
|
| 34 |
-
<h2 class="text-xl font-semibold">Vista Previa</h2>
|
| 35 |
-
<p class="mt-2 text-slate-600">
|
| 36 |
-
Ver cómo se carga el visualizador dentro de un iframe.
|
| 37 |
-
</p>
|
| 38 |
-
</a>
|
| 39 |
-
<a
|
| 40 |
-
href="/app?key=ID_UNICO_DEL_CLIENTE_001"
|
| 41 |
-
class="block rounded-3xl bg-white p-6 shadow-lg hover:shadow-xl transition"
|
| 42 |
-
>
|
| 43 |
-
<h2 class="text-xl font-semibold">App en producción</h2>
|
| 44 |
-
<p class="mt-2 text-slate-600">
|
| 45 |
-
Abrir el visualizador directamente en la ruta del app.
|
| 46 |
-
</p>
|
| 47 |
-
</a>
|
| 48 |
-
</div>
|
| 49 |
-
|
| 50 |
-
<div class="rounded-3xl bg-white p-6 shadow-lg">
|
| 51 |
-
<h2 class="text-xl font-semibold">Sugerencia</h2>
|
| 52 |
-
<p class="mt-2 text-slate-600">
|
| 53 |
-
Mover el app a <code>/app</code> y mantener <code>/</code> como
|
| 54 |
-
landing de desarrollo es una buena práctica. Así el servidor puede
|
| 55 |
-
servir contenido para el equipo sin confundirlo con la ruta del
|
| 56 |
-
producto embebido.
|
| 57 |
-
</p>
|
| 58 |
-
</div>
|
| 59 |
-
</div>
|
| 60 |
-
</body>
|
| 61 |
-
</html>
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="es">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Home - SaaS Dev</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
</head>
|
| 9 |
+
<body class="bg-slate-100 text-slate-900">
|
| 10 |
+
<div class="max-w-5xl mx-auto p-6 space-y-6">
|
| 11 |
+
<header class="rounded-3xl bg-white p-6 shadow-lg">
|
| 12 |
+
<h1 class="text-3xl font-bold">Home de desarrollo</h1>
|
| 13 |
+
<p class="mt-2 text-slate-600">
|
| 14 |
+
Esta página es la landing principal para el equipo de desarrollo.
|
| 15 |
+
Desde aquí puedes acceder al panel administrador, la vista previa y al
|
| 16 |
+
app embebido.
|
| 17 |
+
</p>
|
| 18 |
+
</header>
|
| 19 |
+
|
| 20 |
+
<div class="grid gap-6 md:grid-cols-3">
|
| 21 |
+
<a
|
| 22 |
+
href="/admin"
|
| 23 |
+
class="block rounded-3xl bg-white p-6 shadow-lg hover:shadow-xl transition"
|
| 24 |
+
>
|
| 25 |
+
<h2 class="text-xl font-semibold">Panel de Control</h2>
|
| 26 |
+
<p class="mt-2 text-slate-600">
|
| 27 |
+
Generar API keys, ver clientes y sesiones activas.
|
| 28 |
+
</p>
|
| 29 |
+
</a>
|
| 30 |
+
<a
|
| 31 |
+
href="/preview"
|
| 32 |
+
class="block rounded-3xl bg-white p-6 shadow-lg hover:shadow-xl transition"
|
| 33 |
+
>
|
| 34 |
+
<h2 class="text-xl font-semibold">Vista Previa</h2>
|
| 35 |
+
<p class="mt-2 text-slate-600">
|
| 36 |
+
Ver cómo se carga el visualizador dentro de un iframe.
|
| 37 |
+
</p>
|
| 38 |
+
</a>
|
| 39 |
+
<a
|
| 40 |
+
href="/app?key=ID_UNICO_DEL_CLIENTE_001"
|
| 41 |
+
class="block rounded-3xl bg-white p-6 shadow-lg hover:shadow-xl transition"
|
| 42 |
+
>
|
| 43 |
+
<h2 class="text-xl font-semibold">App en producción</h2>
|
| 44 |
+
<p class="mt-2 text-slate-600">
|
| 45 |
+
Abrir el visualizador directamente en la ruta del app.
|
| 46 |
+
</p>
|
| 47 |
+
</a>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
<div class="rounded-3xl bg-white p-6 shadow-lg">
|
| 51 |
+
<h2 class="text-xl font-semibold">Sugerencia</h2>
|
| 52 |
+
<p class="mt-2 text-slate-600">
|
| 53 |
+
Mover el app a <code>/app</code> y mantener <code>/</code> como
|
| 54 |
+
landing de desarrollo es una buena práctica. Así el servidor puede
|
| 55 |
+
servir contenido para el equipo sin confundirlo con la ruta del
|
| 56 |
+
producto embebido.
|
| 57 |
+
</p>
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
</body>
|
| 61 |
+
</html>
|
backend/logs/app.log
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/main.py
CHANGED
|
@@ -1,176 +1,128 @@
|
|
| 1 |
-
import mimetypes
|
| 2 |
-
import os
|
| 3 |
-
import subprocess
|
| 4 |
-
import
|
| 5 |
-
import
|
| 6 |
-
import
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
from fastapi import
|
| 13 |
-
from fastapi.
|
| 14 |
-
|
| 15 |
-
from
|
| 16 |
-
|
| 17 |
-
from
|
| 18 |
-
from
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
mimetypes.add_type("
|
| 22 |
-
mimetypes.add_type("
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
#
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
app.include_router(
|
| 51 |
-
app.include_router(
|
| 52 |
-
app.include_router(
|
| 53 |
-
app.include_router(
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
app.mount("/
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
if
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
print("[backend] npm not found in PATH — skipping frontend build. Install Node.js/npm to enable auto-build or set SKIP_FRONTEND_BUILD=1 to disable this message.")
|
| 130 |
-
return
|
| 131 |
-
|
| 132 |
-
print("[backend] Ejecutando build del frontend...")
|
| 133 |
-
try:
|
| 134 |
-
result = subprocess.run([npm_path, "run", "build"], cwd=str(FRONTEND_DIR), capture_output=True, text=True)
|
| 135 |
-
except FileNotFoundError:
|
| 136 |
-
print("[backend] Error: npm executable not found when attempting to run build. Skipping frontend build.")
|
| 137 |
-
return
|
| 138 |
-
|
| 139 |
-
if result.returncode != 0:
|
| 140 |
-
print("[backend] Build falló:")
|
| 141 |
-
print(result.stdout)
|
| 142 |
-
print(result.stderr)
|
| 143 |
-
else:
|
| 144 |
-
print("[backend] Build completado correctamente.")
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
def watch_frontend_changes(interval: float = 2.0) -> None:
|
| 148 |
-
last_state = scan_frontend_sources()
|
| 149 |
-
while True:
|
| 150 |
-
time.sleep(interval)
|
| 151 |
-
current_state = scan_frontend_sources()
|
| 152 |
-
if current_state != last_state:
|
| 153 |
-
if last_state:
|
| 154 |
-
print("[backend] Cambio detectado en frontend. Reconstruyendo...")
|
| 155 |
-
run_frontend_build()
|
| 156 |
-
last_state = current_state
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
@app.on_event("startup")
|
| 160 |
-
async def startup_seed_catalog():
|
| 161 |
-
if MONGODB_URI := os.getenv("MONGODB_URI", ""):
|
| 162 |
-
try:
|
| 163 |
-
await seed_catalog()
|
| 164 |
-
except Exception as exc:
|
| 165 |
-
logger.warning("[STARTUP] seed_catalog falló: %s", exc)
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
@app.on_event("startup")
|
| 169 |
-
async def startup_watch_frontend():
|
| 170 |
-
thread = threading.Thread(target=watch_frontend_changes, daemon=True)
|
| 171 |
-
thread.start()
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
if __name__ == "__main__":
|
| 175 |
-
import uvicorn
|
| 176 |
-
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
|
|
|
|
| 1 |
+
import mimetypes
|
| 2 |
+
import os
|
| 3 |
+
import subprocess
|
| 4 |
+
import threading
|
| 5 |
+
import time
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
load_dotenv(Path(__file__).resolve().parent / ".env")
|
| 10 |
+
|
| 11 |
+
from fastapi import FastAPI, Request
|
| 12 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 13 |
+
from fastapi.staticfiles import StaticFiles
|
| 14 |
+
|
| 15 |
+
from core.config import GRADIO_SPACE_URL, logger
|
| 16 |
+
from routers import auth, catalog, media, pages, segmentation, sessions, share
|
| 17 |
+
from routers.catalog import seed_catalog
|
| 18 |
+
from services.sam2_service import lifespan
|
| 19 |
+
|
| 20 |
+
mimetypes.add_type("application/javascript", ".js", strict=True)
|
| 21 |
+
mimetypes.add_type("text/css", ".css", strict=True)
|
| 22 |
+
mimetypes.add_type("image/svg+xml", ".svg", strict=True)
|
| 23 |
+
|
| 24 |
+
logger.info("[STARTUP] GRADIO_SPACE_URL=%s", GRADIO_SPACE_URL or "(not set — using local SAM2)")
|
| 25 |
+
|
| 26 |
+
app = FastAPI(title="Hyper Reality Backend", lifespan=lifespan)
|
| 27 |
+
|
| 28 |
+
app.add_middleware(
|
| 29 |
+
CORSMiddleware,
|
| 30 |
+
allow_origins=["*"],
|
| 31 |
+
allow_credentials=True,
|
| 32 |
+
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
| 33 |
+
allow_headers=["*"],
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@app.middleware("http")
|
| 38 |
+
async def remove_x_frame_options(request: Request, call_next):
|
| 39 |
+
response = await call_next(request)
|
| 40 |
+
if "x-frame-options" in response.headers:
|
| 41 |
+
del response.headers["x-frame-options"]
|
| 42 |
+
response.headers["Content-Security-Policy"] = "frame-ancestors *"
|
| 43 |
+
return response
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# Routers
|
| 47 |
+
app.include_router(pages.router)
|
| 48 |
+
app.include_router(auth.router)
|
| 49 |
+
app.include_router(share.router)
|
| 50 |
+
app.include_router(media.router)
|
| 51 |
+
app.include_router(catalog.router)
|
| 52 |
+
app.include_router(sessions.router)
|
| 53 |
+
app.include_router(segmentation.router)
|
| 54 |
+
|
| 55 |
+
# Static files
|
| 56 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 57 |
+
UPLOADS_DIR = BASE_DIR / "uploads"
|
| 58 |
+
FRONTEND_DIST = BASE_DIR.parent / "frontend" / "dist"
|
| 59 |
+
|
| 60 |
+
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
| 61 |
+
app.mount("/uploads", StaticFiles(directory=UPLOADS_DIR), name="uploads")
|
| 62 |
+
|
| 63 |
+
if (FRONTEND_DIST / "index.html").exists():
|
| 64 |
+
# Montado en "/" como catch-all para SPA — los routers de API tienen prioridad
|
| 65 |
+
app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="frontend")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# Frontend watcher (development helper)
|
| 69 |
+
FRONTEND_DIR = BASE_DIR.parent / "frontend"
|
| 70 |
+
FRONTEND_SRC = FRONTEND_DIR / "src"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def scan_frontend_sources() -> dict:
|
| 74 |
+
if not FRONTEND_SRC.exists():
|
| 75 |
+
return {}
|
| 76 |
+
files = {}
|
| 77 |
+
for path in FRONTEND_SRC.rglob("*"):
|
| 78 |
+
if path.is_file() and path.suffix in {".ts", ".tsx", ".js", ".jsx", ".css", ".json", ".html"}:
|
| 79 |
+
files[path] = path.stat().st_mtime
|
| 80 |
+
for extra in [FRONTEND_DIR / "vite.config.ts", FRONTEND_DIR / "package.json", FRONTEND_DIR / "tsconfig.json"]:
|
| 81 |
+
if extra.exists():
|
| 82 |
+
files[extra] = extra.stat().st_mtime
|
| 83 |
+
return files
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def run_frontend_build() -> None:
|
| 87 |
+
if not FRONTEND_DIR.exists():
|
| 88 |
+
return
|
| 89 |
+
print("[backend] Ejecutando build del frontend...")
|
| 90 |
+
result = subprocess.run(["npm", "run", "build"], cwd=str(FRONTEND_DIR), capture_output=True, text=True)
|
| 91 |
+
if result.returncode != 0:
|
| 92 |
+
print("[backend] Build falló:")
|
| 93 |
+
print(result.stdout)
|
| 94 |
+
print(result.stderr)
|
| 95 |
+
else:
|
| 96 |
+
print("[backend] Build completado correctamente.")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def watch_frontend_changes(interval: float = 2.0) -> None:
|
| 100 |
+
last_state = scan_frontend_sources()
|
| 101 |
+
while True:
|
| 102 |
+
time.sleep(interval)
|
| 103 |
+
current_state = scan_frontend_sources()
|
| 104 |
+
if current_state != last_state:
|
| 105 |
+
if last_state:
|
| 106 |
+
print("[backend] Cambio detectado en frontend. Reconstruyendo...")
|
| 107 |
+
run_frontend_build()
|
| 108 |
+
last_state = current_state
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@app.on_event("startup")
|
| 112 |
+
async def startup_seed_catalog():
|
| 113 |
+
if MONGODB_URI := os.getenv("MONGODB_URI", ""):
|
| 114 |
+
try:
|
| 115 |
+
await seed_catalog()
|
| 116 |
+
except Exception as exc:
|
| 117 |
+
logger.warning("[STARTUP] seed_catalog falló: %s", exc)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@app.on_event("startup")
|
| 121 |
+
async def startup_watch_frontend():
|
| 122 |
+
thread = threading.Thread(target=watch_frontend_changes, daemon=True)
|
| 123 |
+
thread.start()
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
if __name__ == "__main__":
|
| 127 |
+
import uvicorn
|
| 128 |
+
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/models/schemas.py
CHANGED
|
@@ -112,7 +112,5 @@ class ApplyColorRequest(BaseModel):
|
|
| 112 |
class ApplyTextureAIRequest(BaseModel):
|
| 113 |
filename: str
|
| 114 |
original_filename: str = ""
|
| 115 |
-
mask_filename: str
|
| 116 |
prompt: str = ""
|
| 117 |
-
texture_name: str = ""
|
| 118 |
-
|
|
|
|
| 112 |
class ApplyTextureAIRequest(BaseModel):
|
| 113 |
filename: str
|
| 114 |
original_filename: str = ""
|
| 115 |
+
mask_filename: str
|
| 116 |
prompt: str = ""
|
|
|
|
|
|
backend/preview.html
CHANGED
|
@@ -1,242 +1,242 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html lang="es">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8" />
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
-
<title>Vista previa del app</title>
|
| 7 |
-
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
-
</head>
|
| 9 |
-
<body class="bg-slate-100 text-slate-900">
|
| 10 |
-
<div class="max-w-5xl mx-auto p-6 space-y-6">
|
| 11 |
-
<header class="flex items-center justify-between gap-4">
|
| 12 |
-
<div>
|
| 13 |
-
<h1 class="text-3xl font-bold">Vista previa del app</h1>
|
| 14 |
-
<p class="text-slate-600">
|
| 15 |
-
Aquí puedes ver cómo se despliega el visualizador con una API key de
|
| 16 |
-
ejemplo.
|
| 17 |
-
</p>
|
| 18 |
-
</div>
|
| 19 |
-
<a
|
| 20 |
-
href="/admin"
|
| 21 |
-
class="px-4 py-2 bg-slate-800 text-white rounded-xl hover:bg-slate-900"
|
| 22 |
-
>Ir al panel</a
|
| 23 |
-
>
|
| 24 |
-
</header>
|
| 25 |
-
|
| 26 |
-
<div
|
| 27 |
-
class="rounded-3xl overflow-hidden border border-slate-200 shadow-lg"
|
| 28 |
-
>
|
| 29 |
-
<iframe
|
| 30 |
-
id="preview-iframe"
|
| 31 |
-
src=""
|
| 32 |
-
class="w-full min-h-[600px] border-0"
|
| 33 |
-
></iframe>
|
| 34 |
-
</div>
|
| 35 |
-
<div class="rounded-3xl bg-white p-6 shadow-lg">
|
| 36 |
-
<h2 class="text-xl font-semibold mb-3">Puente de desarrollo</h2>
|
| 37 |
-
<p class="text-slate-700 mb-4">
|
| 38 |
-
Esta vista previa se conecta con el app React embebido. Puedes enviar
|
| 39 |
-
comandos y ver las respuestas del iframe.
|
| 40 |
-
</p>
|
| 41 |
-
<div class="grid gap-4 md:grid-cols-2 mb-4">
|
| 42 |
-
<div>
|
| 43 |
-
<label class="block text-sm font-medium text-slate-700 mb-2">
|
| 44 |
-
Cliente de prueba
|
| 45 |
-
</label>
|
| 46 |
-
<select
|
| 47 |
-
id="client-select"
|
| 48 |
-
class="w-full rounded-2xl border border-slate-300 px-4 py-3"
|
| 49 |
-
>
|
| 50 |
-
<option value="ID_UNICO_DEL_CLIENTE_001">
|
| 51 |
-
ID_UNICO_DEL_CLIENTE_001
|
| 52 |
-
</option>
|
| 53 |
-
</select>
|
| 54 |
-
</div>
|
| 55 |
-
<div>
|
| 56 |
-
<label class="block text-sm font-medium text-slate-700 mb-2">
|
| 57 |
-
Comando personalizado
|
| 58 |
-
</label>
|
| 59 |
-
<input
|
| 60 |
-
id="custom-command"
|
| 61 |
-
type="text"
|
| 62 |
-
placeholder="Ej. set-color:#f97316"
|
| 63 |
-
class="w-full rounded-2xl border border-slate-300 px-4 py-3"
|
| 64 |
-
/>
|
| 65 |
-
</div>
|
| 66 |
-
</div>
|
| 67 |
-
<div class="flex flex-wrap gap-3 mb-4">
|
| 68 |
-
<button
|
| 69 |
-
id="load-client"
|
| 70 |
-
class="px-4 py-2 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700"
|
| 71 |
-
>
|
| 72 |
-
Cargar cliente seleccionado
|
| 73 |
-
</button>
|
| 74 |
-
<button
|
| 75 |
-
id="send-ping"
|
| 76 |
-
class="px-4 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700"
|
| 77 |
-
>
|
| 78 |
-
Enviar ping
|
| 79 |
-
</button>
|
| 80 |
-
<button
|
| 81 |
-
id="request-status"
|
| 82 |
-
class="px-4 py-2 bg-slate-200 text-slate-900 rounded-xl hover:bg-slate-300"
|
| 83 |
-
>
|
| 84 |
-
Pedir estado al app
|
| 85 |
-
</button>
|
| 86 |
-
<button
|
| 87 |
-
id="send-custom"
|
| 88 |
-
class="px-4 py-2 bg-green-600 text-white rounded-xl hover:bg-green-700"
|
| 89 |
-
>
|
| 90 |
-
Enviar comando
|
| 91 |
-
</button>
|
| 92 |
-
</div>
|
| 93 |
-
<div
|
| 94 |
-
id="bridge-log"
|
| 95 |
-
class="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-slate-700 h-52 overflow-y-auto"
|
| 96 |
-
></div>
|
| 97 |
-
</div>
|
| 98 |
-
<script>
|
| 99 |
-
const iframe = document.getElementById("preview-iframe");
|
| 100 |
-
const bridgeLog = document.getElementById("bridge-log");
|
| 101 |
-
const clientSelect = document.getElementById("client-select");
|
| 102 |
-
const customCommand = document.getElementById("custom-command");
|
| 103 |
-
|
| 104 |
-
function addLog(message) {
|
| 105 |
-
const item = document.createElement("div");
|
| 106 |
-
item.className = "text-sm mb-2";
|
| 107 |
-
item.textContent = message;
|
| 108 |
-
bridgeLog.appendChild(item);
|
| 109 |
-
bridgeLog.scrollTop = bridgeLog.scrollHeight;
|
| 110 |
-
}
|
| 111 |
-
|
| 112 |
-
async function loadClients() {
|
| 113 |
-
try {
|
| 114 |
-
const res = await fetch("/api/keys");
|
| 115 |
-
const data = await res.json();
|
| 116 |
-
if (!res.ok) {
|
| 117 |
-
throw new Error(data.error || "No se pudo cargar los clientes.");
|
| 118 |
-
}
|
| 119 |
-
clientSelect.innerHTML = data.keys
|
| 120 |
-
.map(
|
| 121 |
-
(key) =>
|
| 122 |
-
`<option value="${key.client_id}">${key.client_id} - ${key.nombre}</option>`,
|
| 123 |
-
)
|
| 124 |
-
.join("");
|
| 125 |
-
addLog(`Clientes cargados: ${data.keys.length}`);
|
| 126 |
-
} catch (error) {
|
| 127 |
-
addLog(`Error al cargar clientes: ${error.message || error}`);
|
| 128 |
-
}
|
| 129 |
-
}
|
| 130 |
-
|
| 131 |
-
async function checkEnvironment() {
|
| 132 |
-
try {
|
| 133 |
-
const res = await fetch("/health");
|
| 134 |
-
const data = await res.json();
|
| 135 |
-
if (!res.ok || !data.frontend_ready) {
|
| 136 |
-
addLog(
|
| 137 |
-
"Ambiente no listo: asegúrate de tener backend y frontend levantados.",
|
| 138 |
-
);
|
| 139 |
-
return false;
|
| 140 |
-
}
|
| 141 |
-
addLog("Ambiente OK: backend y frontend están levantados.");
|
| 142 |
-
return true;
|
| 143 |
-
} catch (error) {
|
| 144 |
-
addLog(
|
| 145 |
-
"No se pudo verificar el ambiente. El backend debe estar corriendo.",
|
| 146 |
-
);
|
| 147 |
-
return false;
|
| 148 |
-
}
|
| 149 |
-
}
|
| 150 |
-
|
| 151 |
-
async function loadPreviewToken() {
|
| 152 |
-
const backendOk = await checkEnvironment();
|
| 153 |
-
if (!backendOk) return;
|
| 154 |
-
const clientId = clientSelect.value || "ID_UNICO_DEL_CLIENTE_001";
|
| 155 |
-
try {
|
| 156 |
-
const res = await fetch("/api/token", {
|
| 157 |
-
method: "POST",
|
| 158 |
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
| 159 |
-
body: `client_id=${encodeURIComponent(clientId)}`,
|
| 160 |
-
});
|
| 161 |
-
const data = await res.json();
|
| 162 |
-
if (!res.ok) {
|
| 163 |
-
throw new Error(data.error || "No se pudo generar token.");
|
| 164 |
-
}
|
| 165 |
-
iframe.src = `/app?token=${encodeURIComponent(data.token)}`;
|
| 166 |
-
addLog(`Token generado para ${clientId}`);
|
| 167 |
-
} catch (error) {
|
| 168 |
-
addLog(`Error generando token: ${error.message || error}`);
|
| 169 |
-
}
|
| 170 |
-
}
|
| 171 |
-
|
| 172 |
-
function sendBridgeCommand(command, payload) {
|
| 173 |
-
if (!iframe.contentWindow) {
|
| 174 |
-
addLog("El iframe aún no está listo.");
|
| 175 |
-
return;
|
| 176 |
-
}
|
| 177 |
-
iframe.contentWindow.postMessage(
|
| 178 |
-
{ type: "preview-command", command, payload },
|
| 179 |
-
"*",
|
| 180 |
-
);
|
| 181 |
-
addLog(
|
| 182 |
-
`Enviado comando al app: ${command}${payload ? ` ${JSON.stringify(payload)}` : ""}`,
|
| 183 |
-
);
|
| 184 |
-
}
|
| 185 |
-
|
| 186 |
-
window.addEventListener("message", (event) => {
|
| 187 |
-
if (event.source !== iframe.contentWindow) return;
|
| 188 |
-
const data = event.data;
|
| 189 |
-
if (!data || typeof data !== "object") return;
|
| 190 |
-
|
| 191 |
-
if (data.type === "saas-session-active") {
|
| 192 |
-
addLog(`React indicó sesión activa: ${data.nombreCliente}`);
|
| 193 |
-
}
|
| 194 |
-
if (data.type === "app-loaded") {
|
| 195 |
-
addLog(`React cargado con cliente: ${data.clientId}`);
|
| 196 |
-
}
|
| 197 |
-
if (data.type === "preview-response") {
|
| 198 |
-
addLog(`React responde: ${JSON.stringify(data)}`);
|
| 199 |
-
}
|
| 200 |
-
});
|
| 201 |
-
|
| 202 |
-
document.getElementById("load-client").addEventListener("click", () => {
|
| 203 |
-
loadPreviewToken();
|
| 204 |
-
});
|
| 205 |
-
|
| 206 |
-
document.getElementById("send-ping").addEventListener("click", () => {
|
| 207 |
-
sendBridgeCommand("ping");
|
| 208 |
-
});
|
| 209 |
-
|
| 210 |
-
document
|
| 211 |
-
.getElementById("request-status")
|
| 212 |
-
.addEventListener("click", () => {
|
| 213 |
-
sendBridgeCommand("status");
|
| 214 |
-
});
|
| 215 |
-
|
| 216 |
-
document.getElementById("send-custom").addEventListener("click", () => {
|
| 217 |
-
const commandText = customCommand.value.trim();
|
| 218 |
-
if (!commandText) {
|
| 219 |
-
addLog("Ingresa un comando personalizado.");
|
| 220 |
-
return;
|
| 221 |
-
}
|
| 222 |
-
const [command, payloadString] = commandText.split(":", 2);
|
| 223 |
-
let payload;
|
| 224 |
-
if (payloadString) {
|
| 225 |
-
payload = { value: payloadString };
|
| 226 |
-
}
|
| 227 |
-
sendBridgeCommand(command, payload);
|
| 228 |
-
});
|
| 229 |
-
|
| 230 |
-
loadClients().then(loadPreviewToken);
|
| 231 |
-
</script>
|
| 232 |
-
|
| 233 |
-
<div class="rounded-3xl bg-white p-6 shadow-lg">
|
| 234 |
-
<h2 class="text-xl font-semibold mb-3">Nota</h2>
|
| 235 |
-
<p class="text-slate-700">
|
| 236 |
-
Esta vista previa genera un token de acceso para el cliente
|
| 237 |
-
<strong>ID_UNICO_DEL_CLIENTE_001</strong> y carga el app con él.
|
| 238 |
-
</p>
|
| 239 |
-
</div>
|
| 240 |
-
</div>
|
| 241 |
-
</body>
|
| 242 |
-
</html>
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="es">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Vista previa del app</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
</head>
|
| 9 |
+
<body class="bg-slate-100 text-slate-900">
|
| 10 |
+
<div class="max-w-5xl mx-auto p-6 space-y-6">
|
| 11 |
+
<header class="flex items-center justify-between gap-4">
|
| 12 |
+
<div>
|
| 13 |
+
<h1 class="text-3xl font-bold">Vista previa del app</h1>
|
| 14 |
+
<p class="text-slate-600">
|
| 15 |
+
Aquí puedes ver cómo se despliega el visualizador con una API key de
|
| 16 |
+
ejemplo.
|
| 17 |
+
</p>
|
| 18 |
+
</div>
|
| 19 |
+
<a
|
| 20 |
+
href="/admin"
|
| 21 |
+
class="px-4 py-2 bg-slate-800 text-white rounded-xl hover:bg-slate-900"
|
| 22 |
+
>Ir al panel</a
|
| 23 |
+
>
|
| 24 |
+
</header>
|
| 25 |
+
|
| 26 |
+
<div
|
| 27 |
+
class="rounded-3xl overflow-hidden border border-slate-200 shadow-lg"
|
| 28 |
+
>
|
| 29 |
+
<iframe
|
| 30 |
+
id="preview-iframe"
|
| 31 |
+
src=""
|
| 32 |
+
class="w-full min-h-[600px] border-0"
|
| 33 |
+
></iframe>
|
| 34 |
+
</div>
|
| 35 |
+
<div class="rounded-3xl bg-white p-6 shadow-lg">
|
| 36 |
+
<h2 class="text-xl font-semibold mb-3">Puente de desarrollo</h2>
|
| 37 |
+
<p class="text-slate-700 mb-4">
|
| 38 |
+
Esta vista previa se conecta con el app React embebido. Puedes enviar
|
| 39 |
+
comandos y ver las respuestas del iframe.
|
| 40 |
+
</p>
|
| 41 |
+
<div class="grid gap-4 md:grid-cols-2 mb-4">
|
| 42 |
+
<div>
|
| 43 |
+
<label class="block text-sm font-medium text-slate-700 mb-2">
|
| 44 |
+
Cliente de prueba
|
| 45 |
+
</label>
|
| 46 |
+
<select
|
| 47 |
+
id="client-select"
|
| 48 |
+
class="w-full rounded-2xl border border-slate-300 px-4 py-3"
|
| 49 |
+
>
|
| 50 |
+
<option value="ID_UNICO_DEL_CLIENTE_001">
|
| 51 |
+
ID_UNICO_DEL_CLIENTE_001
|
| 52 |
+
</option>
|
| 53 |
+
</select>
|
| 54 |
+
</div>
|
| 55 |
+
<div>
|
| 56 |
+
<label class="block text-sm font-medium text-slate-700 mb-2">
|
| 57 |
+
Comando personalizado
|
| 58 |
+
</label>
|
| 59 |
+
<input
|
| 60 |
+
id="custom-command"
|
| 61 |
+
type="text"
|
| 62 |
+
placeholder="Ej. set-color:#f97316"
|
| 63 |
+
class="w-full rounded-2xl border border-slate-300 px-4 py-3"
|
| 64 |
+
/>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
<div class="flex flex-wrap gap-3 mb-4">
|
| 68 |
+
<button
|
| 69 |
+
id="load-client"
|
| 70 |
+
class="px-4 py-2 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700"
|
| 71 |
+
>
|
| 72 |
+
Cargar cliente seleccionado
|
| 73 |
+
</button>
|
| 74 |
+
<button
|
| 75 |
+
id="send-ping"
|
| 76 |
+
class="px-4 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700"
|
| 77 |
+
>
|
| 78 |
+
Enviar ping
|
| 79 |
+
</button>
|
| 80 |
+
<button
|
| 81 |
+
id="request-status"
|
| 82 |
+
class="px-4 py-2 bg-slate-200 text-slate-900 rounded-xl hover:bg-slate-300"
|
| 83 |
+
>
|
| 84 |
+
Pedir estado al app
|
| 85 |
+
</button>
|
| 86 |
+
<button
|
| 87 |
+
id="send-custom"
|
| 88 |
+
class="px-4 py-2 bg-green-600 text-white rounded-xl hover:bg-green-700"
|
| 89 |
+
>
|
| 90 |
+
Enviar comando
|
| 91 |
+
</button>
|
| 92 |
+
</div>
|
| 93 |
+
<div
|
| 94 |
+
id="bridge-log"
|
| 95 |
+
class="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-slate-700 h-52 overflow-y-auto"
|
| 96 |
+
></div>
|
| 97 |
+
</div>
|
| 98 |
+
<script>
|
| 99 |
+
const iframe = document.getElementById("preview-iframe");
|
| 100 |
+
const bridgeLog = document.getElementById("bridge-log");
|
| 101 |
+
const clientSelect = document.getElementById("client-select");
|
| 102 |
+
const customCommand = document.getElementById("custom-command");
|
| 103 |
+
|
| 104 |
+
function addLog(message) {
|
| 105 |
+
const item = document.createElement("div");
|
| 106 |
+
item.className = "text-sm mb-2";
|
| 107 |
+
item.textContent = message;
|
| 108 |
+
bridgeLog.appendChild(item);
|
| 109 |
+
bridgeLog.scrollTop = bridgeLog.scrollHeight;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
async function loadClients() {
|
| 113 |
+
try {
|
| 114 |
+
const res = await fetch("/api/keys");
|
| 115 |
+
const data = await res.json();
|
| 116 |
+
if (!res.ok) {
|
| 117 |
+
throw new Error(data.error || "No se pudo cargar los clientes.");
|
| 118 |
+
}
|
| 119 |
+
clientSelect.innerHTML = data.keys
|
| 120 |
+
.map(
|
| 121 |
+
(key) =>
|
| 122 |
+
`<option value="${key.client_id}">${key.client_id} - ${key.nombre}</option>`,
|
| 123 |
+
)
|
| 124 |
+
.join("");
|
| 125 |
+
addLog(`Clientes cargados: ${data.keys.length}`);
|
| 126 |
+
} catch (error) {
|
| 127 |
+
addLog(`Error al cargar clientes: ${error.message || error}`);
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
async function checkEnvironment() {
|
| 132 |
+
try {
|
| 133 |
+
const res = await fetch("/health");
|
| 134 |
+
const data = await res.json();
|
| 135 |
+
if (!res.ok || !data.frontend_ready) {
|
| 136 |
+
addLog(
|
| 137 |
+
"Ambiente no listo: asegúrate de tener backend y frontend levantados.",
|
| 138 |
+
);
|
| 139 |
+
return false;
|
| 140 |
+
}
|
| 141 |
+
addLog("Ambiente OK: backend y frontend están levantados.");
|
| 142 |
+
return true;
|
| 143 |
+
} catch (error) {
|
| 144 |
+
addLog(
|
| 145 |
+
"No se pudo verificar el ambiente. El backend debe estar corriendo.",
|
| 146 |
+
);
|
| 147 |
+
return false;
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
async function loadPreviewToken() {
|
| 152 |
+
const backendOk = await checkEnvironment();
|
| 153 |
+
if (!backendOk) return;
|
| 154 |
+
const clientId = clientSelect.value || "ID_UNICO_DEL_CLIENTE_001";
|
| 155 |
+
try {
|
| 156 |
+
const res = await fetch("/api/token", {
|
| 157 |
+
method: "POST",
|
| 158 |
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
| 159 |
+
body: `client_id=${encodeURIComponent(clientId)}`,
|
| 160 |
+
});
|
| 161 |
+
const data = await res.json();
|
| 162 |
+
if (!res.ok) {
|
| 163 |
+
throw new Error(data.error || "No se pudo generar token.");
|
| 164 |
+
}
|
| 165 |
+
iframe.src = `/app?token=${encodeURIComponent(data.token)}`;
|
| 166 |
+
addLog(`Token generado para ${clientId}`);
|
| 167 |
+
} catch (error) {
|
| 168 |
+
addLog(`Error generando token: ${error.message || error}`);
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
function sendBridgeCommand(command, payload) {
|
| 173 |
+
if (!iframe.contentWindow) {
|
| 174 |
+
addLog("El iframe aún no está listo.");
|
| 175 |
+
return;
|
| 176 |
+
}
|
| 177 |
+
iframe.contentWindow.postMessage(
|
| 178 |
+
{ type: "preview-command", command, payload },
|
| 179 |
+
"*",
|
| 180 |
+
);
|
| 181 |
+
addLog(
|
| 182 |
+
`Enviado comando al app: ${command}${payload ? ` ${JSON.stringify(payload)}` : ""}`,
|
| 183 |
+
);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
window.addEventListener("message", (event) => {
|
| 187 |
+
if (event.source !== iframe.contentWindow) return;
|
| 188 |
+
const data = event.data;
|
| 189 |
+
if (!data || typeof data !== "object") return;
|
| 190 |
+
|
| 191 |
+
if (data.type === "saas-session-active") {
|
| 192 |
+
addLog(`React indicó sesión activa: ${data.nombreCliente}`);
|
| 193 |
+
}
|
| 194 |
+
if (data.type === "app-loaded") {
|
| 195 |
+
addLog(`React cargado con cliente: ${data.clientId}`);
|
| 196 |
+
}
|
| 197 |
+
if (data.type === "preview-response") {
|
| 198 |
+
addLog(`React responde: ${JSON.stringify(data)}`);
|
| 199 |
+
}
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
document.getElementById("load-client").addEventListener("click", () => {
|
| 203 |
+
loadPreviewToken();
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
document.getElementById("send-ping").addEventListener("click", () => {
|
| 207 |
+
sendBridgeCommand("ping");
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
document
|
| 211 |
+
.getElementById("request-status")
|
| 212 |
+
.addEventListener("click", () => {
|
| 213 |
+
sendBridgeCommand("status");
|
| 214 |
+
});
|
| 215 |
+
|
| 216 |
+
document.getElementById("send-custom").addEventListener("click", () => {
|
| 217 |
+
const commandText = customCommand.value.trim();
|
| 218 |
+
if (!commandText) {
|
| 219 |
+
addLog("Ingresa un comando personalizado.");
|
| 220 |
+
return;
|
| 221 |
+
}
|
| 222 |
+
const [command, payloadString] = commandText.split(":", 2);
|
| 223 |
+
let payload;
|
| 224 |
+
if (payloadString) {
|
| 225 |
+
payload = { value: payloadString };
|
| 226 |
+
}
|
| 227 |
+
sendBridgeCommand(command, payload);
|
| 228 |
+
});
|
| 229 |
+
|
| 230 |
+
loadClients().then(loadPreviewToken);
|
| 231 |
+
</script>
|
| 232 |
+
|
| 233 |
+
<div class="rounded-3xl bg-white p-6 shadow-lg">
|
| 234 |
+
<h2 class="text-xl font-semibold mb-3">Nota</h2>
|
| 235 |
+
<p class="text-slate-700">
|
| 236 |
+
Esta vista previa genera un token de acceso para el cliente
|
| 237 |
+
<strong>ID_UNICO_DEL_CLIENTE_001</strong> y carga el app con él.
|
| 238 |
+
</p>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
</body>
|
| 242 |
+
</html>
|
backend/requirements.txt
CHANGED
|
@@ -9,4 +9,3 @@ pydantic
|
|
| 9 |
python-multipart
|
| 10 |
gradio_client
|
| 11 |
opencv-python-headless
|
| 12 |
-
openai
|
|
|
|
| 9 |
python-multipart
|
| 10 |
gradio_client
|
| 11 |
opencv-python-headless
|
|
|
backend/routers/auth.py
CHANGED
|
@@ -21,7 +21,7 @@ def _get_col():
|
|
| 21 |
global _client, _db, _col
|
| 22 |
if _col is None:
|
| 23 |
if not MONGODB_URI:
|
| 24 |
-
|
| 25 |
_client = AsyncIOMotorClient(MONGODB_URI)
|
| 26 |
_db = _client["hyper_reality"]
|
| 27 |
_col = _db["clients"]
|
|
@@ -87,14 +87,6 @@ async def startup():
|
|
| 87 |
|
| 88 |
@router.post("/api/token")
|
| 89 |
async def token(client_id: str = Form(...)):
|
| 90 |
-
if not MONGODB_URI:
|
| 91 |
-
# fallback in-memory lookup
|
| 92 |
-
doc = next((c for c in _DEFAULT_CLIENTS if c["_id"] == client_id), None)
|
| 93 |
-
if not doc:
|
| 94 |
-
return JSONResponse(content={"error": "client_id inválido"}, status_code=400)
|
| 95 |
-
tok = generate_token_for_client(client_id)
|
| 96 |
-
return JSONResponse(content={"token": tok, "expires_in": TOKEN_TTL})
|
| 97 |
-
|
| 98 |
col = _get_col()
|
| 99 |
doc = await col.find_one({"_id": client_id})
|
| 100 |
if not doc:
|
|
@@ -111,13 +103,10 @@ async def config(client_id: str = Query(None), token: str = Query(None)):
|
|
| 111 |
return JSONResponse(content={"error": "token inválido o expirado"}, status_code=401)
|
| 112 |
if not client_id:
|
| 113 |
return JSONResponse(content={"error": "client_id o token requerido"}, status_code=400)
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
col = _get_col()
|
| 119 |
-
doc = await col.find_one({"_id": client_id})
|
| 120 |
-
datos = doc or {"nombre": "Cliente Desconocido", "color_primario": "#f97316", "created_at": _now_iso()}
|
| 121 |
datos.pop("_id", None)
|
| 122 |
ACTIVE_SESSIONS[client_id] = _now_iso()
|
| 123 |
return JSONResponse(content={"client_id": client_id, **datos})
|
|
@@ -137,18 +126,6 @@ async def session_start(client_id: str = Form(None), token: str = Form(None)):
|
|
| 137 |
|
| 138 |
@router.get("/api/keys")
|
| 139 |
async def api_keys():
|
| 140 |
-
if not MONGODB_URI:
|
| 141 |
-
keys = [
|
| 142 |
-
{
|
| 143 |
-
"client_id": d["_id"],
|
| 144 |
-
"nombre": d.get("nombre", ""),
|
| 145 |
-
"color_primario": d.get("color_primario", ""),
|
| 146 |
-
"created_at": d.get("created_at", ""),
|
| 147 |
-
}
|
| 148 |
-
for d in _DEFAULT_CLIENTS
|
| 149 |
-
]
|
| 150 |
-
return JSONResponse(content={"keys": keys, "count": len(keys)})
|
| 151 |
-
|
| 152 |
col = _get_col()
|
| 153 |
docs = await col.find({}).to_list(length=500)
|
| 154 |
keys = [
|
|
@@ -167,11 +144,8 @@ async def api_keys():
|
|
| 167 |
async def generate_key(request: Request, nombre: str = Form(...), color_primario: str = Form("#8b5cf6")):
|
| 168 |
new_key = f"CLIENTE_{uuid4().hex[:8].upper()}"
|
| 169 |
doc = {"_id": new_key, "nombre": nombre, "color_primario": color_primario, "created_at": _now_iso()}
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
else:
|
| 173 |
-
col = _get_col()
|
| 174 |
-
await col.insert_one(doc)
|
| 175 |
base_url = str(request.base_url).rstrip("/")
|
| 176 |
if request.headers.get("x-forwarded-proto") == "https" and base_url.startswith("http://"):
|
| 177 |
base_url = "https://" + base_url[7:]
|
|
@@ -190,13 +164,6 @@ async def generate_key(request: Request, nombre: str = Form(...), color_primario
|
|
| 190 |
|
| 191 |
@router.delete("/api/keys/{client_id}")
|
| 192 |
async def delete_key(client_id: str):
|
| 193 |
-
if not MONGODB_URI:
|
| 194 |
-
before = len(_DEFAULT_CLIENTS)
|
| 195 |
-
_DEFAULT_CLIENTS[:] = [c for c in _DEFAULT_CLIENTS if c["_id"] != client_id]
|
| 196 |
-
if len(_DEFAULT_CLIENTS) == before:
|
| 197 |
-
return JSONResponse(content={"error": "client_id no encontrado"}, status_code=404)
|
| 198 |
-
return JSONResponse(content={"status": "eliminado", "client_id": client_id})
|
| 199 |
-
|
| 200 |
col = _get_col()
|
| 201 |
result = await col.delete_one({"_id": client_id})
|
| 202 |
if result.deleted_count == 0:
|
|
|
|
| 21 |
global _client, _db, _col
|
| 22 |
if _col is None:
|
| 23 |
if not MONGODB_URI:
|
| 24 |
+
raise RuntimeError("MONGODB_URI no configurado")
|
| 25 |
_client = AsyncIOMotorClient(MONGODB_URI)
|
| 26 |
_db = _client["hyper_reality"]
|
| 27 |
_col = _db["clients"]
|
|
|
|
| 87 |
|
| 88 |
@router.post("/api/token")
|
| 89 |
async def token(client_id: str = Form(...)):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
col = _get_col()
|
| 91 |
doc = await col.find_one({"_id": client_id})
|
| 92 |
if not doc:
|
|
|
|
| 103 |
return JSONResponse(content={"error": "token inválido o expirado"}, status_code=401)
|
| 104 |
if not client_id:
|
| 105 |
return JSONResponse(content={"error": "client_id o token requerido"}, status_code=400)
|
| 106 |
+
|
| 107 |
+
col = _get_col()
|
| 108 |
+
doc = await col.find_one({"_id": client_id})
|
| 109 |
+
datos = doc or {"nombre": "Cliente Desconocido", "color_primario": "#f97316", "created_at": _now_iso()}
|
|
|
|
|
|
|
|
|
|
| 110 |
datos.pop("_id", None)
|
| 111 |
ACTIVE_SESSIONS[client_id] = _now_iso()
|
| 112 |
return JSONResponse(content={"client_id": client_id, **datos})
|
|
|
|
| 126 |
|
| 127 |
@router.get("/api/keys")
|
| 128 |
async def api_keys():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
col = _get_col()
|
| 130 |
docs = await col.find({}).to_list(length=500)
|
| 131 |
keys = [
|
|
|
|
| 144 |
async def generate_key(request: Request, nombre: str = Form(...), color_primario: str = Form("#8b5cf6")):
|
| 145 |
new_key = f"CLIENTE_{uuid4().hex[:8].upper()}"
|
| 146 |
doc = {"_id": new_key, "nombre": nombre, "color_primario": color_primario, "created_at": _now_iso()}
|
| 147 |
+
col = _get_col()
|
| 148 |
+
await col.insert_one(doc)
|
|
|
|
|
|
|
|
|
|
| 149 |
base_url = str(request.base_url).rstrip("/")
|
| 150 |
if request.headers.get("x-forwarded-proto") == "https" and base_url.startswith("http://"):
|
| 151 |
base_url = "https://" + base_url[7:]
|
|
|
|
| 164 |
|
| 165 |
@router.delete("/api/keys/{client_id}")
|
| 166 |
async def delete_key(client_id: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
col = _get_col()
|
| 168 |
result = await col.delete_one({"_id": client_id})
|
| 169 |
if result.deleted_count == 0:
|
backend/routers/catalog.py
CHANGED
|
@@ -1,240 +1,240 @@
|
|
| 1 |
-
import os
|
| 2 |
-
from datetime import datetime
|
| 3 |
-
|
| 4 |
-
from fastapi import APIRouter
|
| 5 |
-
from fastapi.responses import JSONResponse
|
| 6 |
-
from motor.motor_asyncio import AsyncIOMotorClient
|
| 7 |
-
from pydantic import BaseModel
|
| 8 |
-
|
| 9 |
-
router = APIRouter(prefix="/api/catalog")
|
| 10 |
-
|
| 11 |
-
MONGODB_URI = os.getenv("MONGODB_URI", "")
|
| 12 |
-
_client: AsyncIOMotorClient | None = None
|
| 13 |
-
_db = None
|
| 14 |
-
_col = None
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
def _get_col():
|
| 18 |
-
global _client, _db, _col
|
| 19 |
-
if _col is None:
|
| 20 |
-
if not MONGODB_URI:
|
| 21 |
-
raise RuntimeError("MONGODB_URI no configurado")
|
| 22 |
-
_client = AsyncIOMotorClient(MONGODB_URI)
|
| 23 |
-
_db = _client["hyper_reality"]
|
| 24 |
-
_col = _db["catalog"]
|
| 25 |
-
return _col
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
# ── Datos iniciales (se insertan solo si la colección está vacía) ─────────────
|
| 29 |
-
_SEED = [
|
| 30 |
-
{
|
| 31 |
-
"_id": "acm",
|
| 32 |
-
"nombre": "ACM (Aluminio Compuesto)",
|
| 33 |
-
"tipo": "paredes",
|
| 34 |
-
"descripcion": "Paneles de aluminio compuesto para fachadas y exteriores",
|
| 35 |
-
"especificaciones": [
|
| 36 |
-
"Espesor de ACM 4mm.",
|
| 37 |
-
"Medida 1.22m x 2.44m",
|
| 38 |
-
"Facil Mantenimiento.",
|
| 39 |
-
"Espesor de Aluminio 0.40mm.",
|
| 40 |
-
"Se puede doblar o biselar",
|
| 41 |
-
],
|
| 42 |
-
"url_detalle": "https://heyzine.com/flip-book/447fe3eb8e.html#page/16",
|
| 43 |
-
"productos": [
|
| 44 |
-
{"id": "acm_white", "nombre": "Glossy White", "textura": "Texture_ACM/ACM_White.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_White.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 45 |
-
{"id": "acm_amarillo", "nombre": "Amarillo", "textura": "Texture_ACM/ACM_Amarillo.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Amarillo.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 46 |
-
{"id": "acm_orange", "nombre": "Glossy Orange", "textura": "Texture_ACM/ACM_Orange.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Orange.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 47 |
-
{"id": "acm_red", "nombre": "Glossy Red", "textura": "Texture_ACM/ACM_Glossy_Red.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Glossy_Red.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 48 |
-
{"id": "acm_light_blue", "nombre": "Light Blue", "textura": "Texture_ACM/ACM_Light_Blue.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Light_Blue.png", "dimensiones": ["1.22x2.44"]},
|
| 49 |
-
{"id": "acm_azul", "nombre": "Azul", "textura": "Texture_ACM/ACM_Azul.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Azul.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 50 |
-
{"id": "acm_verde_hn", "nombre": "Verde HN", "textura": "Texture_ACM/ACM_Verde_HN.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde_HN.png", "dimensiones": ["1.22x2.44"]},
|
| 51 |
-
{"id": "acm_verde_lima", "nombre": "Verde Lima", "textura": "Texture_ACM/ACM_Verde_Lima.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde_Lima.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 52 |
-
{"id": "acm_verde", "nombre": "Verde", "textura": "Texture_ACM/ACM_Verde.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 53 |
-
{"id": "acm_madera_clara","nombre": "Madera Clara", "textura": "Texture_ACM/ACM_Madera_Clara.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Madera_Clara.png", "dimensiones": ["1.22x2.44"]},
|
| 54 |
-
{"id": "acm_roble", "nombre": "Roble (Oak)", "textura": "Texture_ACM/ACM_ROBLE(OAK).png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_ROBLE(OAK).png", "dimensiones": ["1.22x2.44"]},
|
| 55 |
-
{"id": "acm_grafito", "nombre": "Grafito", "textura": "Texture_ACM/ACM_Grafito.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Grafito.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 56 |
-
{"id": "acm_metalic", "nombre": "Silver Metallic", "textura": "Texture_ACM/ACM_Metalic.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Metalic.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 57 |
-
{"id": "acm_mouse_grey", "nombre": "Mouse Grey", "textura": "Texture_ACM/ACM_MouseGrey.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_MouseGrey.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 58 |
-
{"id": "acm_matte_black", "nombre": "Matte Black", "textura": "Texture_ACM/ACM_Matteblack.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Matteblack.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 59 |
-
{"id": "acm_glossy_black","nombre": "Glossy Black", "textura": "Texture_ACM/ACM_Glossy_Black.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Glossy_Black.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 60 |
-
],
|
| 61 |
-
"created_at": "2026-04-20T00:00:00Z",
|
| 62 |
-
},
|
| 63 |
-
{
|
| 64 |
-
"_id": "wpc",
|
| 65 |
-
"nombre": "WPC (Exterior e Interior)",
|
| 66 |
-
"tipo": "paredes",
|
| 67 |
-
"descripcion": "Los paneles de WPC se utilizan como revestimiento decorativo para paredes. No se deforma, no requiere mantenimiento constante y tiene mayor durabilidad. Crea mayor estetica e instalacion rapida.",
|
| 68 |
-
"especificaciones": [
|
| 69 |
-
"Revestimiento decorativo para paredes.",
|
| 70 |
-
"No se deforma ni requiere mantenimiento constante.",
|
| 71 |
-
"Mayor durabilidad y estetica.",
|
| 72 |
-
"Instalacion rapida.",
|
| 73 |
-
"Ideal para: sala principal (pared protagonista), comedor, interior de oficina, pasillo o entradas.",
|
| 74 |
-
],
|
| 75 |
-
"url_detalle": "https://heyzine.com/flip-book/447fe3eb8e.html#page/39",
|
| 76 |
-
"productos": [
|
| 77 |
-
{"id": "WPC_madera_oscuro", "nombre": "WPC Madera Oscuro", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_oscuro.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_oscuro.png", "dimensiones": ["2.90x0.25"]},
|
| 78 |
-
{"id": "WPC_madera_claro", "nombre": "WPC Madera Claro", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_claro.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_claro.png", "dimensiones": ["2.90x0.25"]},
|
| 79 |
-
{"id": "WPC_madera_gris", "nombre": "WPC Madera Gris", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_gris.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_gris.png", "dimensiones": ["2.90x0.25"]},
|
| 80 |
-
{"id": "WPC_negro", "nombre": "WPC Negro", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_negro.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_negro.png", "dimensiones": ["2.90x0.25"]},
|
| 81 |
-
],
|
| 82 |
-
"created_at": "2026-05-07T00:00:00Z",
|
| 83 |
-
},
|
| 84 |
-
{
|
| 85 |
-
"_id": "wpc_deck",
|
| 86 |
-
"nombre": "WPC Deck",
|
| 87 |
-
"tipo": "suelos",
|
| 88 |
-
"descripcion": "Deck de WPC para exteriores e interiores. Ideal para terrazas, jardines, bordes de piscina y espacios al aire libre.",
|
| 89 |
-
"especificaciones": [
|
| 90 |
-
"Resistencia a la intemperie.",
|
| 91 |
-
"Bajo mantenimiento.",
|
| 92 |
-
"Respetuosa con el medio ambiente.",
|
| 93 |
-
"Esteticamente agradable.",
|
| 94 |
-
],
|
| 95 |
-
"url_detalle": "https://heyzine.com/flip-book/447fe3eb8e.html#page/38",
|
| 96 |
-
"productos": [
|
| 97 |
-
{"id": "DECK_madera", "nombre": "Deck Madera", "textura": "
|
| 98 |
-
{"id": "DECK_madera_oscuro", "nombre": "Deck Madera Oscuro", "textura": "
|
| 99 |
-
{"id": "DECK_gris", "nombre": "Deck Gris", "textura": "
|
| 100 |
-
],
|
| 101 |
-
"created_at": "2026-05-08T00:00:00Z",
|
| 102 |
-
},
|
| 103 |
-
]
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
async def seed_catalog() -> None:
|
| 107 |
-
col = _get_col()
|
| 108 |
-
count = await col.count_documents({})
|
| 109 |
-
if count == 0:
|
| 110 |
-
await col.insert_many(_SEED)
|
| 111 |
-
return
|
| 112 |
-
|
| 113 |
-
# Migrar documentos existentes: añadir campos que falten según _SEED
|
| 114 |
-
for seed_item in _SEED:
|
| 115 |
-
doc = await col.find_one({"_id": seed_item["_id"]})
|
| 116 |
-
if not doc:
|
| 117 |
-
continue
|
| 118 |
-
patch = {k: v for k, v in seed_item.items() if k not in doc and k != "_id"}
|
| 119 |
-
if patch:
|
| 120 |
-
await col.update_one({"_id": seed_item["_id"]}, {"$set": patch})
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
def _serialize(doc: dict) -> dict:
|
| 124 |
-
out = dict(doc)
|
| 125 |
-
out["id"] = str(out.pop("_id"))
|
| 126 |
-
return out
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
def _seed_as_response() -> list[dict]:
|
| 130 |
-
return [{**{k: v for k, v in item.items() if k != "_id"}, "id": item["_id"]} for item in _SEED]
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
# ── Endpoints de lectura ──────────────────────────────────────────────────────
|
| 134 |
-
|
| 135 |
-
@router.get("/textures")
|
| 136 |
-
async def get_texture_catalog() -> JSONResponse:
|
| 137 |
-
try:
|
| 138 |
-
col = _get_col()
|
| 139 |
-
docs = await col.find({}).to_list(length=200)
|
| 140 |
-
if docs:
|
| 141 |
-
return JSONResponse(content={"categories": [_serialize(d) for d in docs]})
|
| 142 |
-
except Exception:
|
| 143 |
-
pass
|
| 144 |
-
# Fallback a datos estáticos si MongoDB no está disponible o la colección está vacía
|
| 145 |
-
return JSONResponse(content={"categories": _seed_as_response()})
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
@router.get("/textures/{category_id}")
|
| 149 |
-
async def get_texture_category(category_id: str) -> JSONResponse:
|
| 150 |
-
try:
|
| 151 |
-
col = _get_col()
|
| 152 |
-
doc = await col.find_one({"_id": category_id})
|
| 153 |
-
if doc:
|
| 154 |
-
return JSONResponse(content=_serialize(doc))
|
| 155 |
-
except Exception:
|
| 156 |
-
pass
|
| 157 |
-
fallback = next((item for item in _SEED if item["_id"] == category_id), None)
|
| 158 |
-
if fallback:
|
| 159 |
-
return JSONResponse(content=_seed_as_response()[_SEED.index(fallback)])
|
| 160 |
-
return JSONResponse(content={"detail": f"Categoria '{category_id}' no encontrada"}, status_code=404)
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
# ── Modelos ───────────────────────────────────────────────────────────────────
|
| 164 |
-
|
| 165 |
-
class ProductoItem(BaseModel):
|
| 166 |
-
id: str
|
| 167 |
-
nombre: str
|
| 168 |
-
textura: str
|
| 169 |
-
url_preview: str
|
| 170 |
-
dimensiones: list[str] = []
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
class CategoriaBody(BaseModel):
|
| 174 |
-
id: str
|
| 175 |
-
nombre: str
|
| 176 |
-
tipo: str = "paredes"
|
| 177 |
-
descripcion: str = ""
|
| 178 |
-
especificaciones: list[str] = []
|
| 179 |
-
url_detalle: str = ""
|
| 180 |
-
productos: list[ProductoItem] = []
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
# ── Endpoints de escritura ────────────────────────────────────────────────────
|
| 184 |
-
|
| 185 |
-
@router.post("/category")
|
| 186 |
-
async def add_category(body: CategoriaBody) -> JSONResponse:
|
| 187 |
-
col = _get_col()
|
| 188 |
-
existing = await col.find_one({"_id": body.id})
|
| 189 |
-
if existing:
|
| 190 |
-
return JSONResponse(content={"error": f"Categoria '{body.id}' ya existe"}, status_code=409)
|
| 191 |
-
doc = body.model_dump()
|
| 192 |
-
doc["_id"] = doc.pop("id")
|
| 193 |
-
doc["created_at"] = datetime.utcnow().isoformat() + "Z"
|
| 194 |
-
await col.insert_one(doc)
|
| 195 |
-
return JSONResponse(content={"ok": True, "id": body.id}, status_code=201)
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
@router.put("/category/{category_id}")
|
| 199 |
-
async def update_category(category_id: str, body: CategoriaBody) -> JSONResponse:
|
| 200 |
-
col = _get_col()
|
| 201 |
-
doc = body.model_dump()
|
| 202 |
-
doc.pop("id", None)
|
| 203 |
-
doc["updated_at"] = datetime.utcnow().isoformat() + "Z"
|
| 204 |
-
result = await col.update_one({"_id": category_id}, {"$set": doc})
|
| 205 |
-
if result.matched_count == 0:
|
| 206 |
-
return JSONResponse(content={"error": "Categoria no encontrada"}, status_code=404)
|
| 207 |
-
return JSONResponse(content={"ok": True})
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
@router.delete("/category/{category_id}")
|
| 211 |
-
async def delete_category(category_id: str) -> JSONResponse:
|
| 212 |
-
col = _get_col()
|
| 213 |
-
result = await col.delete_one({"_id": category_id})
|
| 214 |
-
if result.deleted_count == 0:
|
| 215 |
-
return JSONResponse(content={"error": "Categoria no encontrada"}, status_code=404)
|
| 216 |
-
return JSONResponse(content={"ok": True, "deleted": category_id})
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
@router.post("/category/{category_id}/product")
|
| 220 |
-
async def add_product(category_id: str, product: ProductoItem) -> JSONResponse:
|
| 221 |
-
col = _get_col()
|
| 222 |
-
result = await col.update_one(
|
| 223 |
-
{"_id": category_id, "productos.id": {"$ne": product.id}},
|
| 224 |
-
{"$push": {"productos": product.model_dump()}, "$set": {"updated_at": datetime.utcnow().isoformat() + "Z"}},
|
| 225 |
-
)
|
| 226 |
-
if result.matched_count == 0:
|
| 227 |
-
return JSONResponse(content={"error": "Categoria no encontrada o producto duplicado"}, status_code=409)
|
| 228 |
-
return JSONResponse(content={"ok": True}, status_code=201)
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
@router.delete("/category/{category_id}/product/{product_id}")
|
| 232 |
-
async def delete_product(category_id: str, product_id: str) -> JSONResponse:
|
| 233 |
-
col = _get_col()
|
| 234 |
-
result = await col.update_one(
|
| 235 |
-
{"_id": category_id},
|
| 236 |
-
{"$pull": {"productos": {"id": product_id}}, "$set": {"updated_at": datetime.utcnow().isoformat() + "Z"}},
|
| 237 |
-
)
|
| 238 |
-
if result.matched_count == 0:
|
| 239 |
-
return JSONResponse(content={"error": "Categoria no encontrada"}, status_code=404)
|
| 240 |
-
return JSONResponse(content={"ok": True})
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
|
| 4 |
+
from fastapi import APIRouter
|
| 5 |
+
from fastapi.responses import JSONResponse
|
| 6 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/api/catalog")
|
| 10 |
+
|
| 11 |
+
MONGODB_URI = os.getenv("MONGODB_URI", "")
|
| 12 |
+
_client: AsyncIOMotorClient | None = None
|
| 13 |
+
_db = None
|
| 14 |
+
_col = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _get_col():
|
| 18 |
+
global _client, _db, _col
|
| 19 |
+
if _col is None:
|
| 20 |
+
if not MONGODB_URI:
|
| 21 |
+
raise RuntimeError("MONGODB_URI no configurado")
|
| 22 |
+
_client = AsyncIOMotorClient(MONGODB_URI)
|
| 23 |
+
_db = _client["hyper_reality"]
|
| 24 |
+
_col = _db["catalog"]
|
| 25 |
+
return _col
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ── Datos iniciales (se insertan solo si la colección está vacía) ─────────────
|
| 29 |
+
_SEED = [
|
| 30 |
+
{
|
| 31 |
+
"_id": "acm",
|
| 32 |
+
"nombre": "ACM (Aluminio Compuesto)",
|
| 33 |
+
"tipo": "paredes",
|
| 34 |
+
"descripcion": "Paneles de aluminio compuesto para fachadas y exteriores",
|
| 35 |
+
"especificaciones": [
|
| 36 |
+
"Espesor de ACM 4mm.",
|
| 37 |
+
"Medida 1.22m x 2.44m",
|
| 38 |
+
"Facil Mantenimiento.",
|
| 39 |
+
"Espesor de Aluminio 0.40mm.",
|
| 40 |
+
"Se puede doblar o biselar",
|
| 41 |
+
],
|
| 42 |
+
"url_detalle": "https://heyzine.com/flip-book/447fe3eb8e.html#page/16",
|
| 43 |
+
"productos": [
|
| 44 |
+
{"id": "acm_white", "nombre": "Glossy White", "textura": "Texture_ACM/ACM_White.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_White.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 45 |
+
{"id": "acm_amarillo", "nombre": "Amarillo", "textura": "Texture_ACM/ACM_Amarillo.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Amarillo.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 46 |
+
{"id": "acm_orange", "nombre": "Glossy Orange", "textura": "Texture_ACM/ACM_Orange.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Orange.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 47 |
+
{"id": "acm_red", "nombre": "Glossy Red", "textura": "Texture_ACM/ACM_Glossy_Red.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Glossy_Red.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 48 |
+
{"id": "acm_light_blue", "nombre": "Light Blue", "textura": "Texture_ACM/ACM_Light_Blue.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Light_Blue.png", "dimensiones": ["1.22x2.44"]},
|
| 49 |
+
{"id": "acm_azul", "nombre": "Azul", "textura": "Texture_ACM/ACM_Azul.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Azul.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 50 |
+
{"id": "acm_verde_hn", "nombre": "Verde HN", "textura": "Texture_ACM/ACM_Verde_HN.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde_HN.png", "dimensiones": ["1.22x2.44"]},
|
| 51 |
+
{"id": "acm_verde_lima", "nombre": "Verde Lima", "textura": "Texture_ACM/ACM_Verde_Lima.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde_Lima.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 52 |
+
{"id": "acm_verde", "nombre": "Verde", "textura": "Texture_ACM/ACM_Verde.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 53 |
+
{"id": "acm_madera_clara","nombre": "Madera Clara", "textura": "Texture_ACM/ACM_Madera_Clara.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Madera_Clara.png", "dimensiones": ["1.22x2.44"]},
|
| 54 |
+
{"id": "acm_roble", "nombre": "Roble (Oak)", "textura": "Texture_ACM/ACM_ROBLE(OAK).png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_ROBLE(OAK).png", "dimensiones": ["1.22x2.44"]},
|
| 55 |
+
{"id": "acm_grafito", "nombre": "Grafito", "textura": "Texture_ACM/ACM_Grafito.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Grafito.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 56 |
+
{"id": "acm_metalic", "nombre": "Silver Metallic", "textura": "Texture_ACM/ACM_Metalic.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Metalic.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 57 |
+
{"id": "acm_mouse_grey", "nombre": "Mouse Grey", "textura": "Texture_ACM/ACM_MouseGrey.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_MouseGrey.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 58 |
+
{"id": "acm_matte_black", "nombre": "Matte Black", "textura": "Texture_ACM/ACM_Matteblack.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Matteblack.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 59 |
+
{"id": "acm_glossy_black","nombre": "Glossy Black", "textura": "Texture_ACM/ACM_Glossy_Black.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Glossy_Black.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 60 |
+
],
|
| 61 |
+
"created_at": "2026-04-20T00:00:00Z",
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"_id": "wpc",
|
| 65 |
+
"nombre": "WPC (Exterior e Interior)",
|
| 66 |
+
"tipo": "paredes",
|
| 67 |
+
"descripcion": "Los paneles de WPC se utilizan como revestimiento decorativo para paredes. No se deforma, no requiere mantenimiento constante y tiene mayor durabilidad. Crea mayor estetica e instalacion rapida.",
|
| 68 |
+
"especificaciones": [
|
| 69 |
+
"Revestimiento decorativo para paredes.",
|
| 70 |
+
"No se deforma ni requiere mantenimiento constante.",
|
| 71 |
+
"Mayor durabilidad y estetica.",
|
| 72 |
+
"Instalacion rapida.",
|
| 73 |
+
"Ideal para: sala principal (pared protagonista), comedor, interior de oficina, pasillo o entradas.",
|
| 74 |
+
],
|
| 75 |
+
"url_detalle": "https://heyzine.com/flip-book/447fe3eb8e.html#page/39",
|
| 76 |
+
"productos": [
|
| 77 |
+
{"id": "WPC_madera_oscuro", "nombre": "WPC Madera Oscuro", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_oscuro.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_oscuro.png", "dimensiones": ["2.90x0.25"]},
|
| 78 |
+
{"id": "WPC_madera_claro", "nombre": "WPC Madera Claro", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_claro.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_claro.png", "dimensiones": ["2.90x0.25"]},
|
| 79 |
+
{"id": "WPC_madera_gris", "nombre": "WPC Madera Gris", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_gris.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_gris.png", "dimensiones": ["2.90x0.25"]},
|
| 80 |
+
{"id": "WPC_negro", "nombre": "WPC Negro", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_negro.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_negro.png", "dimensiones": ["2.90x0.25"]},
|
| 81 |
+
],
|
| 82 |
+
"created_at": "2026-05-07T00:00:00Z",
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
"_id": "wpc_deck",
|
| 86 |
+
"nombre": "WPC Deck",
|
| 87 |
+
"tipo": "suelos",
|
| 88 |
+
"descripcion": "Deck de WPC para exteriores e interiores. Ideal para terrazas, jardines, bordes de piscina y espacios al aire libre.",
|
| 89 |
+
"especificaciones": [
|
| 90 |
+
"Resistencia a la intemperie.",
|
| 91 |
+
"Bajo mantenimiento.",
|
| 92 |
+
"Respetuosa con el medio ambiente.",
|
| 93 |
+
"Esteticamente agradable.",
|
| 94 |
+
],
|
| 95 |
+
"url_detalle": "https://heyzine.com/flip-book/447fe3eb8e.html#page/38",
|
| 96 |
+
"productos": [
|
| 97 |
+
{"id": "DECK_madera", "nombre": "Deck Madera", "textura": "Texture_wpc_deck/DECK_madera.png", "url_preview": "/seg/texture-preview/Texture_wpc_deck/DECK_madera.png", "dimensiones": ["2.90x0.14"]},
|
| 98 |
+
{"id": "DECK_madera_oscuro", "nombre": "Deck Madera Oscuro", "textura": "Texture_wpc_deck/DECK_madera_oscuro.png", "url_preview": "/seg/texture-preview/Texture_wpc_deck/DECK_madera_oscuro.png", "dimensiones": ["2.90x0.14"]},
|
| 99 |
+
{"id": "DECK_gris", "nombre": "Deck Gris", "textura": "Texture_wpc_deck/DECK_gris.png", "url_preview": "/seg/texture-preview/Texture_wpc_deck/DECK_gris.png", "dimensiones": ["2.90x0.14"]},
|
| 100 |
+
],
|
| 101 |
+
"created_at": "2026-05-08T00:00:00Z",
|
| 102 |
+
},
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
async def seed_catalog() -> None:
|
| 107 |
+
col = _get_col()
|
| 108 |
+
count = await col.count_documents({})
|
| 109 |
+
if count == 0:
|
| 110 |
+
await col.insert_many(_SEED)
|
| 111 |
+
return
|
| 112 |
+
|
| 113 |
+
# Migrar documentos existentes: añadir campos que falten según _SEED
|
| 114 |
+
for seed_item in _SEED:
|
| 115 |
+
doc = await col.find_one({"_id": seed_item["_id"]})
|
| 116 |
+
if not doc:
|
| 117 |
+
continue
|
| 118 |
+
patch = {k: v for k, v in seed_item.items() if k not in doc and k != "_id"}
|
| 119 |
+
if patch:
|
| 120 |
+
await col.update_one({"_id": seed_item["_id"]}, {"$set": patch})
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _serialize(doc: dict) -> dict:
|
| 124 |
+
out = dict(doc)
|
| 125 |
+
out["id"] = str(out.pop("_id"))
|
| 126 |
+
return out
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _seed_as_response() -> list[dict]:
|
| 130 |
+
return [{**{k: v for k, v in item.items() if k != "_id"}, "id": item["_id"]} for item in _SEED]
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ── Endpoints de lectura ──────────────────────────────────────────────────────
|
| 134 |
+
|
| 135 |
+
@router.get("/textures")
|
| 136 |
+
async def get_texture_catalog() -> JSONResponse:
|
| 137 |
+
try:
|
| 138 |
+
col = _get_col()
|
| 139 |
+
docs = await col.find({}).to_list(length=200)
|
| 140 |
+
if docs:
|
| 141 |
+
return JSONResponse(content={"categories": [_serialize(d) for d in docs]})
|
| 142 |
+
except Exception:
|
| 143 |
+
pass
|
| 144 |
+
# Fallback a datos estáticos si MongoDB no está disponible o la colección está vacía
|
| 145 |
+
return JSONResponse(content={"categories": _seed_as_response()})
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@router.get("/textures/{category_id}")
|
| 149 |
+
async def get_texture_category(category_id: str) -> JSONResponse:
|
| 150 |
+
try:
|
| 151 |
+
col = _get_col()
|
| 152 |
+
doc = await col.find_one({"_id": category_id})
|
| 153 |
+
if doc:
|
| 154 |
+
return JSONResponse(content=_serialize(doc))
|
| 155 |
+
except Exception:
|
| 156 |
+
pass
|
| 157 |
+
fallback = next((item for item in _SEED if item["_id"] == category_id), None)
|
| 158 |
+
if fallback:
|
| 159 |
+
return JSONResponse(content=_seed_as_response()[_SEED.index(fallback)])
|
| 160 |
+
return JSONResponse(content={"detail": f"Categoria '{category_id}' no encontrada"}, status_code=404)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ── Modelos ───────────────────────────────────────────────────────────────────
|
| 164 |
+
|
| 165 |
+
class ProductoItem(BaseModel):
|
| 166 |
+
id: str
|
| 167 |
+
nombre: str
|
| 168 |
+
textura: str
|
| 169 |
+
url_preview: str
|
| 170 |
+
dimensiones: list[str] = []
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
class CategoriaBody(BaseModel):
|
| 174 |
+
id: str
|
| 175 |
+
nombre: str
|
| 176 |
+
tipo: str = "paredes"
|
| 177 |
+
descripcion: str = ""
|
| 178 |
+
especificaciones: list[str] = []
|
| 179 |
+
url_detalle: str = ""
|
| 180 |
+
productos: list[ProductoItem] = []
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ── Endpoints de escritura ────────────────────────────────────────────────────
|
| 184 |
+
|
| 185 |
+
@router.post("/category")
|
| 186 |
+
async def add_category(body: CategoriaBody) -> JSONResponse:
|
| 187 |
+
col = _get_col()
|
| 188 |
+
existing = await col.find_one({"_id": body.id})
|
| 189 |
+
if existing:
|
| 190 |
+
return JSONResponse(content={"error": f"Categoria '{body.id}' ya existe"}, status_code=409)
|
| 191 |
+
doc = body.model_dump()
|
| 192 |
+
doc["_id"] = doc.pop("id")
|
| 193 |
+
doc["created_at"] = datetime.utcnow().isoformat() + "Z"
|
| 194 |
+
await col.insert_one(doc)
|
| 195 |
+
return JSONResponse(content={"ok": True, "id": body.id}, status_code=201)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
@router.put("/category/{category_id}")
|
| 199 |
+
async def update_category(category_id: str, body: CategoriaBody) -> JSONResponse:
|
| 200 |
+
col = _get_col()
|
| 201 |
+
doc = body.model_dump()
|
| 202 |
+
doc.pop("id", None)
|
| 203 |
+
doc["updated_at"] = datetime.utcnow().isoformat() + "Z"
|
| 204 |
+
result = await col.update_one({"_id": category_id}, {"$set": doc})
|
| 205 |
+
if result.matched_count == 0:
|
| 206 |
+
return JSONResponse(content={"error": "Categoria no encontrada"}, status_code=404)
|
| 207 |
+
return JSONResponse(content={"ok": True})
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
@router.delete("/category/{category_id}")
|
| 211 |
+
async def delete_category(category_id: str) -> JSONResponse:
|
| 212 |
+
col = _get_col()
|
| 213 |
+
result = await col.delete_one({"_id": category_id})
|
| 214 |
+
if result.deleted_count == 0:
|
| 215 |
+
return JSONResponse(content={"error": "Categoria no encontrada"}, status_code=404)
|
| 216 |
+
return JSONResponse(content={"ok": True, "deleted": category_id})
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
@router.post("/category/{category_id}/product")
|
| 220 |
+
async def add_product(category_id: str, product: ProductoItem) -> JSONResponse:
|
| 221 |
+
col = _get_col()
|
| 222 |
+
result = await col.update_one(
|
| 223 |
+
{"_id": category_id, "productos.id": {"$ne": product.id}},
|
| 224 |
+
{"$push": {"productos": product.model_dump()}, "$set": {"updated_at": datetime.utcnow().isoformat() + "Z"}},
|
| 225 |
+
)
|
| 226 |
+
if result.matched_count == 0:
|
| 227 |
+
return JSONResponse(content={"error": "Categoria no encontrada o producto duplicado"}, status_code=409)
|
| 228 |
+
return JSONResponse(content={"ok": True}, status_code=201)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
@router.delete("/category/{category_id}/product/{product_id}")
|
| 232 |
+
async def delete_product(category_id: str, product_id: str) -> JSONResponse:
|
| 233 |
+
col = _get_col()
|
| 234 |
+
result = await col.update_one(
|
| 235 |
+
{"_id": category_id},
|
| 236 |
+
{"$pull": {"productos": {"id": product_id}}, "$set": {"updated_at": datetime.utcnow().isoformat() + "Z"}},
|
| 237 |
+
)
|
| 238 |
+
if result.matched_count == 0:
|
| 239 |
+
return JSONResponse(content={"error": "Categoria no encontrada"}, status_code=404)
|
| 240 |
+
return JSONResponse(content={"ok": True})
|
backend/routers/segmentation.py
CHANGED
|
@@ -1,760 +1,723 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Segmentation router - todos los endpoints del editor de texturas con SAM2.
|
| 3 |
-
Prefijo: /seg
|
| 4 |
-
"""
|
| 5 |
-
import asyncio
|
| 6 |
-
import uuid
|
| 7 |
-
from datetime import datetime, timezone
|
| 8 |
-
from pathlib import Path
|
| 9 |
-
from typing import Any, cast
|
| 10 |
-
|
| 11 |
-
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, UploadFile
|
| 12 |
-
from fastapi.responses import FileResponse, HTMLResponse, Response
|
| 13 |
-
|
| 14 |
-
from core.config import (
|
| 15 |
-
FRONTEND_DEBUG,
|
| 16 |
-
OUTPUT_DIR,
|
| 17 |
-
SD_JOB_STALE_SECONDS,
|
| 18 |
-
SD_QUICK_TIMEOUT_SECONDS,
|
| 19 |
-
UPLOAD_DIR,
|
| 20 |
-
UPLOAD_JOB_STALE_SECONDS,
|
| 21 |
-
VIDEO_OUTPUT_DIR,
|
| 22 |
-
VIDEO_UPLOAD_DIR,
|
| 23 |
-
load_classic_dashboard_html,
|
| 24 |
-
log_timing_end,
|
| 25 |
-
log_timing_start,
|
| 26 |
-
logger,
|
| 27 |
-
utc_now_iso,
|
| 28 |
-
)
|
| 29 |
-
from pydantic import BaseModel
|
| 30 |
-
|
| 31 |
-
from models.schemas import (
|
| 32 |
-
ApplyColorRequest,
|
| 33 |
-
ApplyTextureAIRequest,
|
| 34 |
-
ApplyTextureRequest,
|
| 35 |
-
ExteriorBrickRequest,
|
| 36 |
-
ExteriorDepthRequest,
|
| 37 |
-
ExteriorGrabCutRequest,
|
| 38 |
-
ExteriorHybridRequest,
|
| 39 |
-
ExteriorSuggestRequest,
|
| 40 |
-
GuidedSegmentRequest,
|
| 41 |
-
SceneAnalyzeRequest,
|
| 42 |
-
SegmentAdaptiveRequest,
|
| 43 |
-
SegmentVideoRequest,
|
| 44 |
-
)
|
| 45 |
-
from services.image_service import (
|
| 46 |
-
prepare_and_store_upload,
|
| 47 |
-
run_upload_job,
|
| 48 |
-
save_label_map_for_owner,
|
| 49 |
-
)
|
| 50 |
-
from services.inpainting_service import run_inpainting_job, run_inpainting_sync
|
| 51 |
-
from services.sam2_service import jobs, jobs_lock, release_resources
|
| 52 |
-
from services.
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
"
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
file
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
"
|
| 130 |
-
"
|
| 131 |
-
"
|
| 132 |
-
"
|
| 133 |
-
"
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
"
|
| 141 |
-
"
|
| 142 |
-
"
|
| 143 |
-
"
|
| 144 |
-
"
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
)
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
"
|
| 171 |
-
"
|
| 172 |
-
"
|
| 173 |
-
"
|
| 174 |
-
"
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
payload.
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
"
|
| 216 |
-
"
|
| 217 |
-
"
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
payload.
|
| 248 |
-
payload.
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
)
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
label_map_arr
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
)
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
"
|
| 273 |
-
"
|
| 274 |
-
"
|
| 275 |
-
"
|
| 276 |
-
"
|
| 277 |
-
"
|
| 278 |
-
"
|
| 279 |
-
"
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
"
|
| 334 |
-
"
|
| 335 |
-
"
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
result["
|
| 341 |
-
result["
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
except
|
| 358 |
-
raise
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
except
|
| 368 |
-
raise
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
except
|
| 378 |
-
raise
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
except
|
| 388 |
-
raise
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
except
|
| 398 |
-
raise
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
log_timing_end("APPLY_TEXTURE_AI", started)
|
| 437 |
-
try:
|
| 438 |
-
release_resources()
|
| 439 |
-
except Exception:
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
"
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
@router.
|
| 620 |
-
async def
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
return
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
@router.
|
| 657 |
-
async def
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
if
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
if not
|
| 699 |
-
raise HTTPException(status_code=
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
)
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
return {"segments": segments_meta, "count": len(segments_meta)}
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
@router.get("/masks/meta/{filename}")
|
| 729 |
-
async def get_mask_metadata(filename: str) -> dict:
|
| 730 |
-
import json as _json
|
| 731 |
-
safe = Path(filename).name
|
| 732 |
-
if not safe:
|
| 733 |
-
raise HTTPException(status_code=400, detail="Invalid filename")
|
| 734 |
-
meta_path = UPLOAD_DIR / "masks" / f"{safe}_labels_meta.json"
|
| 735 |
-
if not meta_path.exists() or not meta_path.is_file():
|
| 736 |
-
raise HTTPException(status_code=404, detail="Segment metadata not found")
|
| 737 |
-
try:
|
| 738 |
-
return _json.loads(meta_path.read_text(encoding="utf-8"))
|
| 739 |
-
except Exception as exc:
|
| 740 |
-
raise HTTPException(status_code=500, detail=f"Failed to read metadata: {exc}") from exc
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
@router.get("/masks/{filename}")
|
| 744 |
-
async def get_mask_labels(filename: str) -> FileResponse:
|
| 745 |
-
if Path(filename).name != filename:
|
| 746 |
-
raise HTTPException(status_code=400, detail="Invalid file name")
|
| 747 |
-
label_path = UPLOAD_DIR / "masks" / f"{filename}_labels.png"
|
| 748 |
-
if not label_path.exists() or not label_path.is_file():
|
| 749 |
-
raise HTTPException(status_code=404, detail="Label map not found")
|
| 750 |
-
return FileResponse(label_path)
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
@router.get("/ai/{filename}")
|
| 754 |
-
async def get_ai_image(filename: str) -> FileResponse:
|
| 755 |
-
if Path(filename).name != filename:
|
| 756 |
-
raise HTTPException(status_code=400, detail="Invalid file name")
|
| 757 |
-
out_path = OUTPUT_DIR / filename
|
| 758 |
-
if not out_path.exists() or not out_path.is_file():
|
| 759 |
-
raise HTTPException(status_code=404, detail="AI output image not found")
|
| 760 |
-
return FileResponse(out_path)
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Segmentation router - todos los endpoints del editor de texturas con SAM2.
|
| 3 |
+
Prefijo: /seg
|
| 4 |
+
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, cast
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, UploadFile
|
| 12 |
+
from fastapi.responses import FileResponse, HTMLResponse, Response
|
| 13 |
+
|
| 14 |
+
from core.config import (
|
| 15 |
+
FRONTEND_DEBUG,
|
| 16 |
+
OUTPUT_DIR,
|
| 17 |
+
SD_JOB_STALE_SECONDS,
|
| 18 |
+
SD_QUICK_TIMEOUT_SECONDS,
|
| 19 |
+
UPLOAD_DIR,
|
| 20 |
+
UPLOAD_JOB_STALE_SECONDS,
|
| 21 |
+
VIDEO_OUTPUT_DIR,
|
| 22 |
+
VIDEO_UPLOAD_DIR,
|
| 23 |
+
load_classic_dashboard_html,
|
| 24 |
+
log_timing_end,
|
| 25 |
+
log_timing_start,
|
| 26 |
+
logger,
|
| 27 |
+
utc_now_iso,
|
| 28 |
+
)
|
| 29 |
+
from pydantic import BaseModel
|
| 30 |
+
|
| 31 |
+
from models.schemas import (
|
| 32 |
+
ApplyColorRequest,
|
| 33 |
+
ApplyTextureAIRequest,
|
| 34 |
+
ApplyTextureRequest,
|
| 35 |
+
ExteriorBrickRequest,
|
| 36 |
+
ExteriorDepthRequest,
|
| 37 |
+
ExteriorGrabCutRequest,
|
| 38 |
+
ExteriorHybridRequest,
|
| 39 |
+
ExteriorSuggestRequest,
|
| 40 |
+
GuidedSegmentRequest,
|
| 41 |
+
SceneAnalyzeRequest,
|
| 42 |
+
SegmentAdaptiveRequest,
|
| 43 |
+
SegmentVideoRequest,
|
| 44 |
+
)
|
| 45 |
+
from services.image_service import (
|
| 46 |
+
prepare_and_store_upload,
|
| 47 |
+
run_upload_job,
|
| 48 |
+
save_label_map_for_owner,
|
| 49 |
+
)
|
| 50 |
+
from services.inpainting_service import run_inpainting_job, run_inpainting_sync
|
| 51 |
+
from services.sam2_service import jobs, jobs_lock, release_resources
|
| 52 |
+
from services.scene_service import (
|
| 53 |
+
build_adaptive_plan,
|
| 54 |
+
generate_label_map,
|
| 55 |
+
infer_scene_type,
|
| 56 |
+
normalize_priority,
|
| 57 |
+
normalize_scene_hint,
|
| 58 |
+
rank_exterior_candidates,
|
| 59 |
+
rank_interior_candidates,
|
| 60 |
+
)
|
| 61 |
+
from services.segmentation_service import (
|
| 62 |
+
generate_guided_label_map,
|
| 63 |
+
parse_mask_index,
|
| 64 |
+
parse_rgb_color,
|
| 65 |
+
segment_exterior_brick_sync,
|
| 66 |
+
segment_exterior_depth_sync,
|
| 67 |
+
segment_exterior_grabcut_sync,
|
| 68 |
+
segment_exterior_hybrid_sync,
|
| 69 |
+
segment_video_sync,
|
| 70 |
+
)
|
| 71 |
+
from services.texture_service import (
|
| 72 |
+
apply_local_texture_sync,
|
| 73 |
+
build_texture_preview_jpeg,
|
| 74 |
+
generate_texture_variations,
|
| 75 |
+
list_available_textures,
|
| 76 |
+
resolve_texture_path,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
import cv2
|
| 80 |
+
|
| 81 |
+
router = APIRouter(prefix="/seg")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@router.get("/", response_class=HTMLResponse)
|
| 85 |
+
async def home() -> HTMLResponse:
|
| 86 |
+
dashboard_html = load_classic_dashboard_html().replace(
|
| 87 |
+
"__FRONTEND_DEBUG_ENABLED__",
|
| 88 |
+
"true" if FRONTEND_DEBUG else "false",
|
| 89 |
+
)
|
| 90 |
+
return HTMLResponse(content=dashboard_html)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@router.post("/upload_video")
|
| 94 |
+
async def upload_video(file: UploadFile = File(...)) -> dict[str, Any]:
|
| 95 |
+
if not file.content_type or not file.content_type.startswith("video/"):
|
| 96 |
+
raise HTTPException(status_code=400, detail="Only video files are allowed")
|
| 97 |
+
|
| 98 |
+
safe_name = Path(file.filename or "uploaded_video").name
|
| 99 |
+
if not safe_name:
|
| 100 |
+
raise HTTPException(status_code=400, detail="Invalid filename")
|
| 101 |
+
|
| 102 |
+
destination = VIDEO_UPLOAD_DIR / safe_name
|
| 103 |
+
content = await file.read()
|
| 104 |
+
if not content:
|
| 105 |
+
raise HTTPException(status_code=400, detail="Uploaded video is empty")
|
| 106 |
+
|
| 107 |
+
destination.write_bytes(content)
|
| 108 |
+
return {
|
| 109 |
+
"message": "Video uploaded successfully",
|
| 110 |
+
"filename": safe_name,
|
| 111 |
+
"url": f"/seg/video/{safe_name}",
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@router.post("/upload_async")
|
| 116 |
+
async def upload_image_async(
|
| 117 |
+
background_tasks: BackgroundTasks,
|
| 118 |
+
file: UploadFile = File(...),
|
| 119 |
+
) -> dict[str, Any]:
|
| 120 |
+
if not file.content_type or not file.content_type.startswith("image/"):
|
| 121 |
+
raise HTTPException(status_code=400, detail="Only image files are allowed")
|
| 122 |
+
|
| 123 |
+
content = await file.read()
|
| 124 |
+
job_id = uuid.uuid4().hex
|
| 125 |
+
with jobs_lock:
|
| 126 |
+
jobs[job_id] = {
|
| 127 |
+
"kind": "upload",
|
| 128 |
+
"status": "processing",
|
| 129 |
+
"stage": "queued",
|
| 130 |
+
"progress": 2,
|
| 131 |
+
"message": "Queued for segmentation",
|
| 132 |
+
"created_at": utc_now_iso(),
|
| 133 |
+
"updated_at": utc_now_iso(),
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
background_tasks.add_task(run_upload_job, job_id, content, file.filename or "uploaded_image")
|
| 137 |
+
return {
|
| 138 |
+
"processing": True,
|
| 139 |
+
"job_id": job_id,
|
| 140 |
+
"status": "processing",
|
| 141 |
+
"stage": "queued",
|
| 142 |
+
"progress": 2,
|
| 143 |
+
"message": "Upload accepted. Segmentation started in background.",
|
| 144 |
+
"status_url": f"/seg/jobs/{job_id}",
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@router.post("/segment_guided")
|
| 149 |
+
async def segment_guided(payload: GuidedSegmentRequest) -> dict[str, Any]:
|
| 150 |
+
started = log_timing_start("SEGMENT_GUIDED")
|
| 151 |
+
try:
|
| 152 |
+
from services.image_service import load_image_rgb_for_edit
|
| 153 |
+
safe_name, image_rgb = await asyncio.to_thread(load_image_rgb_for_edit, payload.filename)
|
| 154 |
+
label_map, ranked_scores = await asyncio.to_thread(
|
| 155 |
+
generate_guided_label_map,
|
| 156 |
+
image_rgb,
|
| 157 |
+
[list(point) for point in payload.point_coords],
|
| 158 |
+
list(payload.point_labels),
|
| 159 |
+
list(payload.box_xyxy) if payload.box_xyxy is not None else [],
|
| 160 |
+
payload.multimask_output,
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
guided_owner = f"{Path(safe_name).stem}_guided.jpg"
|
| 164 |
+
label_owner = await asyncio.to_thread(save_label_map_for_owner, guided_owner, label_map)
|
| 165 |
+
available_indices = list(range(1, len(ranked_scores) + 1))
|
| 166 |
+
|
| 167 |
+
return {
|
| 168 |
+
"message": "Guided segmentation completed",
|
| 169 |
+
"filename": safe_name,
|
| 170 |
+
"original_filename_for_apply": label_owner,
|
| 171 |
+
"mask_count": len(ranked_scores),
|
| 172 |
+
"available_mask_indices": available_indices,
|
| 173 |
+
"recommended_mask_index": 1,
|
| 174 |
+
"scores": [round(score, 6) for score in ranked_scores],
|
| 175 |
+
}
|
| 176 |
+
finally:
|
| 177 |
+
log_timing_end("SEGMENT_GUIDED", started)
|
| 178 |
+
try:
|
| 179 |
+
release_resources()
|
| 180 |
+
except Exception:
|
| 181 |
+
logger.exception("Error releasing resources after SEGMENT_GUIDED")
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
@router.post("/suggest_exterior_masks")
|
| 185 |
+
async def suggest_exterior_masks(payload: ExteriorSuggestRequest) -> dict[str, Any]:
|
| 186 |
+
started = log_timing_start("EXTERIOR_SUGGEST")
|
| 187 |
+
try:
|
| 188 |
+
from services.image_service import load_image_rgb_for_edit
|
| 189 |
+
safe_name, image_rgb = await asyncio.to_thread(load_image_rgb_for_edit, payload.filename)
|
| 190 |
+
|
| 191 |
+
label_owner_name = Path(payload.original_filename).name if payload.original_filename else safe_name
|
| 192 |
+
|
| 193 |
+
masks_dir = UPLOAD_DIR / "masks"
|
| 194 |
+
label_path = masks_dir / f"{label_owner_name}_labels.png"
|
| 195 |
+
if not label_path.exists():
|
| 196 |
+
label_map, _ = await asyncio.to_thread(generate_label_map, image_rgb)
|
| 197 |
+
label_owner_name = await asyncio.to_thread(save_label_map_for_owner, label_owner_name, label_map)
|
| 198 |
+
label_path = masks_dir / f"{label_owner_name}_labels.png"
|
| 199 |
+
|
| 200 |
+
label_map_arr = cv2.imread(str(label_path), cv2.IMREAD_GRAYSCALE)
|
| 201 |
+
if label_map_arr is None:
|
| 202 |
+
raise HTTPException(status_code=404, detail="Label map not found")
|
| 203 |
+
|
| 204 |
+
candidates = rank_exterior_candidates(
|
| 205 |
+
label_map_arr,
|
| 206 |
+
payload.top_k,
|
| 207 |
+
target=payload.target,
|
| 208 |
+
min_area_ratio=payload.min_area_ratio,
|
| 209 |
+
max_area_ratio=payload.max_area_ratio,
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
return {
|
| 213 |
+
"message": "Exterior mask suggestions generated",
|
| 214 |
+
"filename": safe_name,
|
| 215 |
+
"original_filename_for_apply": label_owner_name,
|
| 216 |
+
"suggestions": candidates,
|
| 217 |
+
"target": payload.target,
|
| 218 |
+
}
|
| 219 |
+
finally:
|
| 220 |
+
log_timing_end("EXTERIOR_SUGGEST", started)
|
| 221 |
+
try:
|
| 222 |
+
release_resources()
|
| 223 |
+
except Exception:
|
| 224 |
+
logger.exception("Error releasing resources after EXTERIOR_SUGGEST")
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
@router.post("/analyze_scene")
|
| 228 |
+
async def analyze_scene(payload: SceneAnalyzeRequest) -> dict[str, Any]:
|
| 229 |
+
started = log_timing_start("ANALYZE_SCENE")
|
| 230 |
+
try:
|
| 231 |
+
from services.image_service import load_image_rgb_for_edit
|
| 232 |
+
safe_name, image_rgb = await asyncio.to_thread(load_image_rgb_for_edit, payload.filename)
|
| 233 |
+
|
| 234 |
+
label_owner_name = Path(payload.original_filename).name if payload.original_filename else safe_name
|
| 235 |
+
masks_dir = UPLOAD_DIR / "masks"
|
| 236 |
+
label_path = masks_dir / f"{label_owner_name}_labels.png"
|
| 237 |
+
|
| 238 |
+
if not label_path.exists():
|
| 239 |
+
label_map, _ = await asyncio.to_thread(generate_label_map, image_rgb)
|
| 240 |
+
label_owner_name = await asyncio.to_thread(save_label_map_for_owner, label_owner_name, label_map)
|
| 241 |
+
|
| 242 |
+
scene_info = await asyncio.to_thread(
|
| 243 |
+
infer_scene_type,
|
| 244 |
+
image_rgb,
|
| 245 |
+
payload.semantic_keywords,
|
| 246 |
+
payload.exterior_target,
|
| 247 |
+
payload.min_area_ratio,
|
| 248 |
+
payload.max_area_ratio,
|
| 249 |
+
)
|
| 250 |
+
scene_type = scene_info["scene_type"]
|
| 251 |
+
scene_hint = normalize_scene_hint(payload.scene_hint)
|
| 252 |
+
effective_scene = scene_hint if scene_hint != "auto" else scene_type
|
| 253 |
+
|
| 254 |
+
adaptive_plan = build_adaptive_plan(effective_scene, payload.priority, payload.exterior_target)
|
| 255 |
+
|
| 256 |
+
label_map_arr = cv2.imread(str(masks_dir / f"{label_owner_name}_labels.png"), cv2.IMREAD_GRAYSCALE)
|
| 257 |
+
suggestions: list[dict[str, Any]] = []
|
| 258 |
+
if label_map_arr is not None:
|
| 259 |
+
if effective_scene == "exterior":
|
| 260 |
+
suggestions = rank_exterior_candidates(
|
| 261 |
+
label_map_arr, payload.top_k,
|
| 262 |
+
target=payload.exterior_target,
|
| 263 |
+
min_area_ratio=payload.min_area_ratio,
|
| 264 |
+
max_area_ratio=payload.max_area_ratio,
|
| 265 |
+
)
|
| 266 |
+
else:
|
| 267 |
+
suggestions = rank_interior_candidates(label_map_arr, payload.top_k)
|
| 268 |
+
|
| 269 |
+
return {
|
| 270 |
+
"message": "Scene analysis completed",
|
| 271 |
+
"filename": safe_name,
|
| 272 |
+
"original_filename_for_apply": label_owner_name,
|
| 273 |
+
"scene_type": scene_type,
|
| 274 |
+
"effective_scene": effective_scene,
|
| 275 |
+
"confidence": scene_info["confidence"],
|
| 276 |
+
"signals": scene_info["signals"],
|
| 277 |
+
"adaptive_plan": adaptive_plan,
|
| 278 |
+
"suggestions": suggestions,
|
| 279 |
+
"priority": normalize_priority(payload.priority),
|
| 280 |
+
}
|
| 281 |
+
finally:
|
| 282 |
+
log_timing_end("ANALYZE_SCENE", started)
|
| 283 |
+
try:
|
| 284 |
+
release_resources()
|
| 285 |
+
except Exception:
|
| 286 |
+
logger.exception("Error releasing resources after ANALYZE_SCENE")
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
@router.post("/segment_adaptive")
|
| 290 |
+
async def segment_adaptive(payload: SegmentAdaptiveRequest) -> dict[str, Any]:
|
| 291 |
+
started = log_timing_start("SEGMENT_ADAPTIVE")
|
| 292 |
+
try:
|
| 293 |
+
from services.image_service import load_image_rgb_for_edit
|
| 294 |
+
safe_name, image_rgb = await asyncio.to_thread(load_image_rgb_for_edit, payload.filename)
|
| 295 |
+
|
| 296 |
+
scene_info = await asyncio.to_thread(
|
| 297 |
+
infer_scene_type,
|
| 298 |
+
image_rgb,
|
| 299 |
+
payload.semantic_keywords,
|
| 300 |
+
payload.exterior_target,
|
| 301 |
+
)
|
| 302 |
+
scene_hint = normalize_scene_hint(payload.scene_hint)
|
| 303 |
+
effective_scene = scene_hint if scene_hint != "auto" else scene_info["scene_type"]
|
| 304 |
+
priority = normalize_priority(payload.priority)
|
| 305 |
+
adaptive_plan = build_adaptive_plan(effective_scene, priority, payload.exterior_target)
|
| 306 |
+
|
| 307 |
+
label_owner_name = Path(payload.original_filename).name if payload.original_filename else safe_name
|
| 308 |
+
|
| 309 |
+
if effective_scene == "exterior":
|
| 310 |
+
from services.segmentation_service import segment_exterior_depth_sync as seg_depth
|
| 311 |
+
from models.schemas import ExteriorDepthRequest as DepthReq
|
| 312 |
+
|
| 313 |
+
depth_payload = DepthReq(
|
| 314 |
+
filename=payload.filename,
|
| 315 |
+
exterior_target=payload.exterior_target,
|
| 316 |
+
rect_xywh=payload.rect_xywh,
|
| 317 |
+
smooth_strength=1,
|
| 318 |
+
sam2_merge_top_k=12,
|
| 319 |
+
iterations=6,
|
| 320 |
+
use_semantic_hint=True,
|
| 321 |
+
use_depth_hint=True,
|
| 322 |
+
semantic_keywords=payload.semantic_keywords,
|
| 323 |
+
)
|
| 324 |
+
result = await asyncio.to_thread(seg_depth, depth_payload)
|
| 325 |
+
else:
|
| 326 |
+
label_map, _ = await asyncio.to_thread(generate_label_map, image_rgb)
|
| 327 |
+
label_owner_name = await asyncio.to_thread(save_label_map_for_owner, label_owner_name, label_map)
|
| 328 |
+
top_k = 4 if priority == "speed" else (10 if priority == "quality" else 6)
|
| 329 |
+
candidates = rank_interior_candidates(label_map, top_k)
|
| 330 |
+
result = {
|
| 331 |
+
"message": "Interior adaptive segmentation completed",
|
| 332 |
+
"filename": safe_name,
|
| 333 |
+
"original_filename_for_apply": label_owner_name,
|
| 334 |
+
"scene_type": effective_scene,
|
| 335 |
+
"suggestions": candidates,
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
result["adaptive_plan"] = adaptive_plan
|
| 339 |
+
result["detected_scene_type"] = scene_info["scene_type"]
|
| 340 |
+
result["effective_scene"] = effective_scene
|
| 341 |
+
result["scene_confidence"] = scene_info["confidence"]
|
| 342 |
+
return result
|
| 343 |
+
finally:
|
| 344 |
+
log_timing_end("SEGMENT_ADAPTIVE", started)
|
| 345 |
+
try:
|
| 346 |
+
release_resources()
|
| 347 |
+
except Exception:
|
| 348 |
+
logger.exception("Error releasing resources after SEGMENT_ADAPTIVE")
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
@router.post("/segment_video")
|
| 352 |
+
async def segment_video(payload: SegmentVideoRequest) -> dict[str, Any]:
|
| 353 |
+
try:
|
| 354 |
+
return await asyncio.to_thread(segment_video_sync, payload)
|
| 355 |
+
except HTTPException:
|
| 356 |
+
raise
|
| 357 |
+
except Exception as exc:
|
| 358 |
+
raise HTTPException(status_code=500, detail=f"Video segmentation failed: {exc}") from exc
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
@router.post("/segment_exterior_grabcut")
|
| 362 |
+
async def segment_exterior_grabcut(payload: ExteriorGrabCutRequest) -> dict[str, Any]:
|
| 363 |
+
try:
|
| 364 |
+
return await asyncio.to_thread(segment_exterior_grabcut_sync, payload)
|
| 365 |
+
except HTTPException:
|
| 366 |
+
raise
|
| 367 |
+
except Exception as exc:
|
| 368 |
+
raise HTTPException(status_code=500, detail=f"GrabCut segmentation failed: {exc}") from exc
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
@router.post("/segment_exterior_hybrid")
|
| 372 |
+
async def segment_exterior_hybrid(payload: ExteriorHybridRequest) -> dict[str, Any]:
|
| 373 |
+
try:
|
| 374 |
+
return await asyncio.to_thread(segment_exterior_hybrid_sync, payload)
|
| 375 |
+
except HTTPException:
|
| 376 |
+
raise
|
| 377 |
+
except Exception as exc:
|
| 378 |
+
raise HTTPException(status_code=500, detail=f"Hybrid exterior segmentation failed: {exc}") from exc
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
@router.post("/segment_exterior_brick")
|
| 382 |
+
async def segment_exterior_brick(payload: ExteriorBrickRequest) -> dict[str, Any]:
|
| 383 |
+
try:
|
| 384 |
+
return await asyncio.to_thread(segment_exterior_brick_sync, payload)
|
| 385 |
+
except HTTPException:
|
| 386 |
+
raise
|
| 387 |
+
except Exception as exc:
|
| 388 |
+
raise HTTPException(status_code=500, detail=f"Brick segmentation failed: {exc}") from exc
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
@router.post("/segment_exterior_depth")
|
| 392 |
+
async def segment_exterior_depth(payload: ExteriorDepthRequest) -> dict[str, Any]:
|
| 393 |
+
try:
|
| 394 |
+
return await asyncio.to_thread(segment_exterior_depth_sync, payload)
|
| 395 |
+
except HTTPException:
|
| 396 |
+
raise
|
| 397 |
+
except Exception as exc:
|
| 398 |
+
raise HTTPException(status_code=500, detail=f"Depth exterior segmentation failed: {exc}") from exc
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
@router.post("/apply_texture_ai")
|
| 402 |
+
async def apply_texture_ai(
|
| 403 |
+
payload: ApplyTextureAIRequest,
|
| 404 |
+
background_tasks: BackgroundTasks,
|
| 405 |
+
) -> dict[str, Any]:
|
| 406 |
+
started = log_timing_start("APPLY_TEXTURE_AI")
|
| 407 |
+
try:
|
| 408 |
+
result = await asyncio.wait_for(
|
| 409 |
+
asyncio.to_thread(run_inpainting_sync, payload),
|
| 410 |
+
timeout=SD_QUICK_TIMEOUT_SECONDS,
|
| 411 |
+
)
|
| 412 |
+
log_timing_end("APPLY_TEXTURE_AI", started)
|
| 413 |
+
try:
|
| 414 |
+
release_resources()
|
| 415 |
+
except Exception:
|
| 416 |
+
logger.exception("Error releasing resources after APPLY_TEXTURE_AI")
|
| 417 |
+
result["processing"] = False
|
| 418 |
+
return result
|
| 419 |
+
except asyncio.TimeoutError:
|
| 420 |
+
job_id = uuid.uuid4().hex
|
| 421 |
+
with jobs_lock:
|
| 422 |
+
jobs[job_id] = {"status": "processing", "created_at": utc_now_iso(), "updated_at": utc_now_iso()}
|
| 423 |
+
background_tasks.add_task(run_inpainting_job, job_id, payload)
|
| 424 |
+
log_timing_end("APPLY_TEXTURE_AI", started)
|
| 425 |
+
try:
|
| 426 |
+
release_resources()
|
| 427 |
+
except Exception:
|
| 428 |
+
pass
|
| 429 |
+
return {
|
| 430 |
+
"processing": True,
|
| 431 |
+
"job_id": job_id,
|
| 432 |
+
"message": "Inpainting is taking longer than expected and continues in background.",
|
| 433 |
+
"status_url": f"/seg/jobs/{job_id}",
|
| 434 |
+
}
|
| 435 |
+
except HTTPException:
|
| 436 |
+
log_timing_end("APPLY_TEXTURE_AI", started)
|
| 437 |
+
try:
|
| 438 |
+
release_resources()
|
| 439 |
+
except Exception:
|
| 440 |
+
pass
|
| 441 |
+
raise
|
| 442 |
+
except Exception as exc:
|
| 443 |
+
log_timing_end("APPLY_TEXTURE_AI", started)
|
| 444 |
+
try:
|
| 445 |
+
release_resources()
|
| 446 |
+
except Exception:
|
| 447 |
+
pass
|
| 448 |
+
raise HTTPException(status_code=500, detail=f"Inpainting failed: {exc}") from exc
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
@router.get("/jobs/{job_id}")
|
| 452 |
+
async def get_job_status(job_id: str) -> dict[str, Any]:
|
| 453 |
+
with jobs_lock:
|
| 454 |
+
job = jobs.get(job_id)
|
| 455 |
+
|
| 456 |
+
if job is None:
|
| 457 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 458 |
+
|
| 459 |
+
if job.get("status") == "processing":
|
| 460 |
+
kind = str(job.get("kind", "generic"))
|
| 461 |
+
stage = str(job.get("stage", "processing"))
|
| 462 |
+
progress = int(job.get("progress", 0) or 0)
|
| 463 |
+
eta_seconds: int | None = None
|
| 464 |
+
|
| 465 |
+
if kind == "upload" and stage == "segmenting_with_sam2":
|
| 466 |
+
stage_started_at_text = job.get("stage_started_at")
|
| 467 |
+
estimated_seconds = float(job.get("estimated_seconds", 0.0) or 0.0)
|
| 468 |
+
if stage_started_at_text and estimated_seconds > 0:
|
| 469 |
+
try:
|
| 470 |
+
stage_started_at = datetime.fromisoformat(str(stage_started_at_text))
|
| 471 |
+
elapsed = (datetime.now(timezone.utc) - stage_started_at).total_seconds()
|
| 472 |
+
eta_seconds = max(0, int(estimated_seconds - elapsed))
|
| 473 |
+
estimated_progress = int(min(95, 30 + (max(0.0, elapsed) / estimated_seconds) * 60))
|
| 474 |
+
progress = max(progress, estimated_progress)
|
| 475 |
+
except ValueError:
|
| 476 |
+
pass
|
| 477 |
+
|
| 478 |
+
stale_limit_seconds = UPLOAD_JOB_STALE_SECONDS if kind == "upload" else SD_JOB_STALE_SECONDS
|
| 479 |
+
created_at_text = job.get("created_at")
|
| 480 |
+
if created_at_text:
|
| 481 |
+
try:
|
| 482 |
+
created_at = datetime.fromisoformat(str(created_at_text))
|
| 483 |
+
age_seconds = (datetime.now(timezone.utc) - created_at).total_seconds()
|
| 484 |
+
if age_seconds > stale_limit_seconds:
|
| 485 |
+
return {
|
| 486 |
+
"processing": False,
|
| 487 |
+
"status": "timeout",
|
| 488 |
+
"message": "The process is taking too long. Please retry.",
|
| 489 |
+
"job_id": job_id,
|
| 490 |
+
}
|
| 491 |
+
except ValueError:
|
| 492 |
+
pass
|
| 493 |
+
|
| 494 |
+
response: dict[str, Any] = {
|
| 495 |
+
"processing": True,
|
| 496 |
+
"status": "processing",
|
| 497 |
+
"job_id": job_id,
|
| 498 |
+
"kind": kind,
|
| 499 |
+
"stage": stage,
|
| 500 |
+
"progress": progress,
|
| 501 |
+
"message": str(job.get("message", "Still processing.")),
|
| 502 |
+
}
|
| 503 |
+
if eta_seconds is not None:
|
| 504 |
+
response["eta_seconds"] = eta_seconds
|
| 505 |
+
return response
|
| 506 |
+
|
| 507 |
+
if job.get("status") == "done":
|
| 508 |
+
result = cast(dict[str, Any], job.get("result", {}))
|
| 509 |
+
result["processing"] = False
|
| 510 |
+
result["job_id"] = job_id
|
| 511 |
+
result["status"] = "done"
|
| 512 |
+
return result
|
| 513 |
+
|
| 514 |
+
if job.get("status") == "failed":
|
| 515 |
+
return {
|
| 516 |
+
"processing": False,
|
| 517 |
+
"status": "failed",
|
| 518 |
+
"job_id": job_id,
|
| 519 |
+
"message": job.get("error", "Background task failed"),
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
return {"processing": True, "status": "processing", "job_id": job_id, "message": "Still processing."}
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
@router.post("/apply_color")
|
| 526 |
+
async def apply_color(payload: ApplyColorRequest) -> dict[str, Any]:
|
| 527 |
+
started = log_timing_start("APPLY_COLOR")
|
| 528 |
+
try:
|
| 529 |
+
safe_name = Path(payload.filename).name
|
| 530 |
+
if not safe_name:
|
| 531 |
+
raise HTTPException(status_code=400, detail="Invalid filename")
|
| 532 |
+
|
| 533 |
+
label_safe_name = Path(payload.original_filename).name if payload.original_filename else safe_name
|
| 534 |
+
|
| 535 |
+
image_path = UPLOAD_DIR / safe_name
|
| 536 |
+
if not image_path.exists():
|
| 537 |
+
image_path = OUTPUT_DIR / safe_name
|
| 538 |
+
if not image_path.exists() or not image_path.is_file():
|
| 539 |
+
raise HTTPException(status_code=404, detail=f"Image not found: {safe_name}")
|
| 540 |
+
|
| 541 |
+
image_bgr = cv2.imread(str(image_path))
|
| 542 |
+
if image_bgr is None:
|
| 543 |
+
raise HTTPException(status_code=400, detail="Image could not be read")
|
| 544 |
+
|
| 545 |
+
mask_index = parse_mask_index(payload.mask_filename)
|
| 546 |
+
red, green, blue = parse_rgb_color(payload.color)
|
| 547 |
+
|
| 548 |
+
label_path = UPLOAD_DIR / "masks" / f"{label_safe_name}_labels.png"
|
| 549 |
+
label_map = cv2.imread(str(label_path), cv2.IMREAD_GRAYSCALE)
|
| 550 |
+
if label_map is None:
|
| 551 |
+
raise HTTPException(
|
| 552 |
+
status_code=404,
|
| 553 |
+
detail="Label map not found. Upload the image first to generate segments.",
|
| 554 |
+
)
|
| 555 |
+
|
| 556 |
+
segmentation = label_map == mask_index
|
| 557 |
+
if not segmentation.any():
|
| 558 |
+
raise HTTPException(status_code=400, detail=f"Segment index {mask_index} not found in label map.")
|
| 559 |
+
|
| 560 |
+
edited_image = image_bgr.copy()
|
| 561 |
+
edited_image[segmentation] = (blue, green, red)
|
| 562 |
+
|
| 563 |
+
original_stem = Path(label_safe_name).stem
|
| 564 |
+
out_filename = f"{original_stem}_edit.jpg"
|
| 565 |
+
out_path = UPLOAD_DIR / out_filename
|
| 566 |
+
if not cv2.imwrite(str(out_path), edited_image):
|
| 567 |
+
raise HTTPException(status_code=500, detail="Failed to save edited image")
|
| 568 |
+
|
| 569 |
+
return {
|
| 570 |
+
"message": "Color applied successfully",
|
| 571 |
+
"output_filename": out_filename,
|
| 572 |
+
"output_url": f"/seg/image/{out_filename}",
|
| 573 |
+
}
|
| 574 |
+
finally:
|
| 575 |
+
log_timing_end("APPLY_COLOR", started)
|
| 576 |
+
try:
|
| 577 |
+
release_resources()
|
| 578 |
+
except Exception:
|
| 579 |
+
logger.exception("Error releasing resources after APPLY_COLOR")
|
| 580 |
+
|
| 581 |
+
|
| 582 |
+
@router.post("/apply_texture")
|
| 583 |
+
async def apply_texture(payload: ApplyTextureRequest) -> dict[str, Any]:
|
| 584 |
+
try:
|
| 585 |
+
result = await asyncio.to_thread(apply_local_texture_sync, payload)
|
| 586 |
+
result["processing"] = False
|
| 587 |
+
return result
|
| 588 |
+
except HTTPException:
|
| 589 |
+
raise
|
| 590 |
+
except Exception as exc:
|
| 591 |
+
raise HTTPException(status_code=500, detail=f"Texture apply failed: {exc}") from exc
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
@router.get("/textures")
|
| 595 |
+
async def get_textures() -> dict[str, Any]:
|
| 596 |
+
return {"textures": list_available_textures()}
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
class _GenerateVariationsRequest(BaseModel):
|
| 600 |
+
texture_name: str
|
| 601 |
+
|
| 602 |
+
class Config:
|
| 603 |
+
extra = "ignore"
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
@router.post("/textures/generate")
|
| 607 |
+
async def generate_variations(payload: _GenerateVariationsRequest) -> dict[str, Any]:
|
| 608 |
+
if not payload.texture_name:
|
| 609 |
+
raise HTTPException(status_code=400, detail="texture_name is required")
|
| 610 |
+
try:
|
| 611 |
+
variations = await asyncio.to_thread(generate_texture_variations, payload.texture_name)
|
| 612 |
+
return {"variations": variations}
|
| 613 |
+
except HTTPException:
|
| 614 |
+
raise
|
| 615 |
+
except Exception as exc:
|
| 616 |
+
raise HTTPException(status_code=500, detail=f"Variation generation failed: {exc}") from exc
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
@router.get("/texture-preview/{filename:path}")
|
| 620 |
+
async def get_texture_preview(filename: str) -> Response:
|
| 621 |
+
texture_path = resolve_texture_path(filename)
|
| 622 |
+
jpeg = await asyncio.to_thread(build_texture_preview_jpeg, texture_path)
|
| 623 |
+
return Response(content=jpeg, media_type="image/jpeg", headers={"Cache-Control": "public, max-age=3600"})
|
| 624 |
+
|
| 625 |
+
|
| 626 |
+
@router.get("/video/{filename}")
|
| 627 |
+
async def get_video(filename: str) -> FileResponse:
|
| 628 |
+
if Path(filename).name != filename:
|
| 629 |
+
raise HTTPException(status_code=400, detail="Invalid file name")
|
| 630 |
+
video_path = VIDEO_UPLOAD_DIR / filename
|
| 631 |
+
if not video_path.exists() or not video_path.is_file():
|
| 632 |
+
raise HTTPException(status_code=404, detail="Video not found")
|
| 633 |
+
return FileResponse(video_path)
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
@router.get("/output-video/{filename}")
|
| 637 |
+
async def get_output_video(filename: str) -> FileResponse:
|
| 638 |
+
if Path(filename).name != filename:
|
| 639 |
+
raise HTTPException(status_code=400, detail="Invalid file name")
|
| 640 |
+
video_path = VIDEO_OUTPUT_DIR / filename
|
| 641 |
+
if not video_path.exists() or not video_path.is_file():
|
| 642 |
+
raise HTTPException(status_code=404, detail="Output video not found")
|
| 643 |
+
return FileResponse(video_path)
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
@router.get("/image/{filename}")
|
| 647 |
+
async def get_image(filename: str) -> FileResponse:
|
| 648 |
+
if Path(filename).name != filename:
|
| 649 |
+
raise HTTPException(status_code=400, detail="Invalid file name")
|
| 650 |
+
image_path = UPLOAD_DIR / filename
|
| 651 |
+
if not image_path.exists() or not image_path.is_file():
|
| 652 |
+
raise HTTPException(status_code=404, detail="Image not found")
|
| 653 |
+
return FileResponse(image_path)
|
| 654 |
+
|
| 655 |
+
|
| 656 |
+
@router.post("/masks/reclassify/{filename}")
|
| 657 |
+
async def reclassify_mask_metadata(filename: str) -> dict[str, Any]:
|
| 658 |
+
"""Re-run semantic classification on an already-segmented image and overwrite its metadata JSON."""
|
| 659 |
+
import json as _json
|
| 660 |
+
safe = Path(filename).name
|
| 661 |
+
if not safe:
|
| 662 |
+
raise HTTPException(status_code=400, detail="Invalid filename")
|
| 663 |
+
|
| 664 |
+
masks_dir = UPLOAD_DIR / "masks"
|
| 665 |
+
label_path = masks_dir / f"{safe}_labels.png"
|
| 666 |
+
if not label_path.exists():
|
| 667 |
+
raise HTTPException(status_code=404, detail="Label map not found — upload the image first")
|
| 668 |
+
|
| 669 |
+
label_map = cv2.imread(str(label_path), cv2.IMREAD_GRAYSCALE)
|
| 670 |
+
if label_map is None:
|
| 671 |
+
raise HTTPException(status_code=500, detail="Could not read label map")
|
| 672 |
+
|
| 673 |
+
image_path = UPLOAD_DIR / safe
|
| 674 |
+
image_rgb: Any = None
|
| 675 |
+
if image_path.exists():
|
| 676 |
+
img_bgr = cv2.imread(str(image_path))
|
| 677 |
+
if img_bgr is not None:
|
| 678 |
+
image_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
| 679 |
+
|
| 680 |
+
from services.scene_service import classify_all_label_map_segments
|
| 681 |
+
h, w = label_map.shape[:2]
|
| 682 |
+
segments_meta = await asyncio.to_thread(
|
| 683 |
+
classify_all_label_map_segments, label_map, w, h, image_rgb
|
| 684 |
+
)
|
| 685 |
+
meta_path = masks_dir / f"{safe}_labels_meta.json"
|
| 686 |
+
meta_path.write_text(_json.dumps({"segments": segments_meta}, ensure_ascii=False), encoding="utf-8")
|
| 687 |
+
|
| 688 |
+
return {"segments": segments_meta, "count": len(segments_meta)}
|
| 689 |
+
|
| 690 |
+
|
| 691 |
+
@router.get("/masks/meta/{filename}")
|
| 692 |
+
async def get_mask_metadata(filename: str) -> dict:
|
| 693 |
+
import json as _json
|
| 694 |
+
safe = Path(filename).name
|
| 695 |
+
if not safe:
|
| 696 |
+
raise HTTPException(status_code=400, detail="Invalid filename")
|
| 697 |
+
meta_path = UPLOAD_DIR / "masks" / f"{safe}_labels_meta.json"
|
| 698 |
+
if not meta_path.exists() or not meta_path.is_file():
|
| 699 |
+
raise HTTPException(status_code=404, detail="Segment metadata not found")
|
| 700 |
+
try:
|
| 701 |
+
return _json.loads(meta_path.read_text(encoding="utf-8"))
|
| 702 |
+
except Exception as exc:
|
| 703 |
+
raise HTTPException(status_code=500, detail=f"Failed to read metadata: {exc}") from exc
|
| 704 |
+
|
| 705 |
+
|
| 706 |
+
@router.get("/masks/{filename}")
|
| 707 |
+
async def get_mask_labels(filename: str) -> FileResponse:
|
| 708 |
+
if Path(filename).name != filename:
|
| 709 |
+
raise HTTPException(status_code=400, detail="Invalid file name")
|
| 710 |
+
label_path = UPLOAD_DIR / "masks" / f"{filename}_labels.png"
|
| 711 |
+
if not label_path.exists() or not label_path.is_file():
|
| 712 |
+
raise HTTPException(status_code=404, detail="Label map not found")
|
| 713 |
+
return FileResponse(label_path)
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
@router.get("/ai/{filename}")
|
| 717 |
+
async def get_ai_image(filename: str) -> FileResponse:
|
| 718 |
+
if Path(filename).name != filename:
|
| 719 |
+
raise HTTPException(status_code=400, detail="Invalid file name")
|
| 720 |
+
out_path = OUTPUT_DIR / filename
|
| 721 |
+
if not out_path.exists() or not out_path.is_file():
|
| 722 |
+
raise HTTPException(status_code=404, detail="AI output image not found")
|
| 723 |
+
return FileResponse(out_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/run_server.bat
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
-
@echo off
|
| 2 |
-
REM Usa el Python del entorno virtual local dentro de backend y arranca uvicorn con recarga en caliente.
|
| 3 |
-
if exist .venv\Scripts\python.exe (
|
| 4 |
-
set "PYTHON_EXE=.venv\Scripts\python.exe"
|
| 5 |
-
) else if exist .venv\bin\python.exe (
|
| 6 |
-
set "PYTHON_EXE=.venv\bin\python.exe"
|
| 7 |
-
) else (
|
| 8 |
-
echo No se encontró Python en .venv. Crea el entorno virtual primero.
|
| 9 |
-
pause
|
| 10 |
-
exit /b 1
|
| 11 |
-
)
|
| 12 |
-
%PYTHON_EXE% -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
REM Usa el Python del entorno virtual local dentro de backend y arranca uvicorn con recarga en caliente.
|
| 3 |
+
if exist .venv\Scripts\python.exe (
|
| 4 |
+
set "PYTHON_EXE=.venv\Scripts\python.exe"
|
| 5 |
+
) else if exist .venv\bin\python.exe (
|
| 6 |
+
set "PYTHON_EXE=.venv\bin\python.exe"
|
| 7 |
+
) else (
|
| 8 |
+
echo No se encontró Python en .venv. Crea el entorno virtual primero.
|
| 9 |
+
pause
|
| 10 |
+
exit /b 1
|
| 11 |
+
)
|
| 12 |
+
%PYTHON_EXE% -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
backend/services/gradio_client_service.py
CHANGED
|
@@ -1,108 +1,104 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Cliente para llamar al Gradio Space de ZeroGPU (SAM2 + SegFormer + DINO).
|
| 3 |
-
Se activa solo si GRADIO_SPACE_URL está definido en el entorno.
|
| 4 |
-
"""
|
| 5 |
-
import asyncio
|
| 6 |
-
import base64
|
| 7 |
-
import io
|
| 8 |
-
import json
|
| 9 |
-
import logging
|
| 10 |
-
from pathlib import Path
|
| 11 |
-
|
| 12 |
-
import numpy as np
|
| 13 |
-
from PIL import Image
|
| 14 |
-
|
| 15 |
-
from core.config import GRADIO_CPU_FALLBACK_URL, GRADIO_SPACE_URL
|
| 16 |
-
|
| 17 |
-
logger = logging.getLogger(__name__)
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def is_gradio_enabled() -> bool:
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
#
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
if
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
"""
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
logger.
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
"""
|
| 106 |
-
Async wrapper — offloads the blocking call (with GPU→CPU fallback) to a thread.
|
| 107 |
-
"""
|
| 108 |
-
return await asyncio.to_thread(segment_via_gradio_sync, image_path)
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cliente para llamar al Gradio Space de ZeroGPU (SAM2 + SegFormer + DINO).
|
| 3 |
+
Se activa solo si GRADIO_SPACE_URL está definido en el entorno.
|
| 4 |
+
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
import base64
|
| 7 |
+
import io
|
| 8 |
+
import json
|
| 9 |
+
import logging
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
from PIL import Image
|
| 14 |
+
|
| 15 |
+
from core.config import GRADIO_CPU_FALLBACK_URL, GRADIO_SPACE_URL
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def is_gradio_enabled() -> bool:
|
| 21 |
+
return bool(GRADIO_SPACE_URL)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _call_gradio_sync(image_path: Path, space_url: str) -> tuple[np.ndarray, int]:
|
| 25 |
+
"""
|
| 26 |
+
Synchronous Gradio call — safe to invoke from a background thread.
|
| 27 |
+
Returns (label_map, mask_count).
|
| 28 |
+
Raises on any error so the caller can handle fallback.
|
| 29 |
+
"""
|
| 30 |
+
from gradio_client import Client, file # type: ignore
|
| 31 |
+
|
| 32 |
+
# 300s timeout: ZeroGPU cold start + SAM2+DINO inference can take 60-120s
|
| 33 |
+
client = Client(space_url, httpx_kwargs={"timeout": 300.0})
|
| 34 |
+
|
| 35 |
+
# segment_for_backend returns (overlay_image, combined_json_str)
|
| 36 |
+
_overlay_file, combined_json_str = client.predict(
|
| 37 |
+
file(str(image_path)),
|
| 38 |
+
api_name="/segment",
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
if not isinstance(combined_json_str, str):
|
| 42 |
+
raise ValueError(f"Unexpected response type from Gradio Space: {type(combined_json_str)}")
|
| 43 |
+
|
| 44 |
+
combined: dict = json.loads(combined_json_str)
|
| 45 |
+
|
| 46 |
+
if "error" in combined:
|
| 47 |
+
raise RuntimeError(f"Gradio Space error: {combined['error'][:500]}")
|
| 48 |
+
|
| 49 |
+
label_map_b64: str = combined.get("label_map_b64", "")
|
| 50 |
+
if not label_map_b64:
|
| 51 |
+
return np.zeros((1, 1), dtype=np.uint8), 0
|
| 52 |
+
|
| 53 |
+
# Decode PNG-encoded label map (lossless uint8 grayscale)
|
| 54 |
+
label_map_bytes = base64.b64decode(label_map_b64)
|
| 55 |
+
pil_label = Image.open(io.BytesIO(label_map_bytes))
|
| 56 |
+
label_map = np.array(pil_label, dtype=np.uint8)
|
| 57 |
+
mask_count = int(label_map.max())
|
| 58 |
+
|
| 59 |
+
entorno = combined.get("entorno", "?")
|
| 60 |
+
motor = combined.get("motor", "?")
|
| 61 |
+
logger.info(
|
| 62 |
+
"Gradio Space segmentation: entorno=%s motor=%s mask_count=%d",
|
| 63 |
+
entorno, motor, mask_count,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
return label_map, mask_count
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def segment_via_gradio_sync(image_path: Path) -> tuple[np.ndarray, int]:
|
| 70 |
+
"""
|
| 71 |
+
Blocking call to the Gradio Space from a sync context (background task thread).
|
| 72 |
+
Tries the GPU Space first; if it fails, falls back to the CPU Space.
|
| 73 |
+
Raises RuntimeError if both fail or neither is configured.
|
| 74 |
+
"""
|
| 75 |
+
if not is_gradio_enabled():
|
| 76 |
+
raise RuntimeError("GRADIO_SPACE_URL is not configured")
|
| 77 |
+
|
| 78 |
+
gpu_error: Exception | None = None
|
| 79 |
+
try:
|
| 80 |
+
logger.info("Calling GPU Gradio Space: %s", GRADIO_SPACE_URL)
|
| 81 |
+
return _call_gradio_sync(image_path, GRADIO_SPACE_URL)
|
| 82 |
+
except Exception as e:
|
| 83 |
+
gpu_error = e
|
| 84 |
+
logger.warning("GPU Space failed (%s), trying CPU fallback...", gpu_error)
|
| 85 |
+
|
| 86 |
+
if not GRADIO_CPU_FALLBACK_URL:
|
| 87 |
+
raise RuntimeError(f"GPU Gradio Space failed and no CPU fallback configured. Error: {gpu_error}")
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
logger.info("Calling CPU fallback Space: %s", GRADIO_CPU_FALLBACK_URL)
|
| 91 |
+
return _call_gradio_sync(image_path, GRADIO_CPU_FALLBACK_URL)
|
| 92 |
+
except Exception as exc_cpu:
|
| 93 |
+
raise RuntimeError(
|
| 94 |
+
f"Both Gradio Spaces failed.\n"
|
| 95 |
+
f" GPU ({GRADIO_SPACE_URL}): {gpu_error}\n"
|
| 96 |
+
f" CPU ({GRADIO_CPU_FALLBACK_URL}): {exc_cpu}"
|
| 97 |
+
) from exc_cpu
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
async def segment_via_gradio(image_path: Path) -> tuple[np.ndarray, int]:
|
| 101 |
+
"""
|
| 102 |
+
Async wrapper — offloads the blocking call (with GPU→CPU fallback) to a thread.
|
| 103 |
+
"""
|
| 104 |
+
return await asyncio.to_thread(segment_via_gradio_sync, image_path)
|
|
|
|
|
|
|
|
|
|
|
|
backend/services/image_service.py
CHANGED
|
@@ -18,13 +18,13 @@ from core.config import (
|
|
| 18 |
logger,
|
| 19 |
utc_now_iso,
|
| 20 |
)
|
|
|
|
| 21 |
from services.sam2_service import (
|
| 22 |
jobs,
|
| 23 |
jobs_lock,
|
| 24 |
release_resources,
|
| 25 |
)
|
| 26 |
|
| 27 |
-
|
| 28 |
# Imported lazily to avoid circular imports
|
| 29 |
def _get_generate_label_map():
|
| 30 |
from services.scene_service import generate_label_map
|
|
@@ -134,15 +134,47 @@ def run_upload_job(job_id: str, content: bytes, original_name: str) -> None:
|
|
| 134 |
logger.info(f"[JOB {job_id}] segmenting_with_sam2 progress=30 estimated_seconds={estimated_seconds}")
|
| 135 |
|
| 136 |
image_path = UPLOAD_DIR / safe_name
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
result: dict[str, Any] = {
|
| 142 |
"message": "Image uploaded successfully",
|
| 143 |
"filename": safe_name,
|
| 144 |
"url": f"/seg/image/{safe_name}",
|
| 145 |
-
"mask_count":
|
| 146 |
}
|
| 147 |
|
| 148 |
with jobs_lock:
|
|
@@ -151,15 +183,11 @@ def run_upload_job(job_id: str, content: bytes, original_name: str) -> None:
|
|
| 151 |
"status": "done",
|
| 152 |
"stage": "done",
|
| 153 |
"progress": 100,
|
| 154 |
-
"message": "
|
| 155 |
"result": result,
|
| 156 |
"updated_at": utc_now_iso(),
|
| 157 |
}
|
| 158 |
-
logger.info(f"[JOB {job_id}] done
|
| 159 |
-
|
| 160 |
-
# Segmentation-related files (masks, metadata) are skipped in the simplified flow.
|
| 161 |
-
pass
|
| 162 |
-
|
| 163 |
|
| 164 |
except Exception as exc:
|
| 165 |
logger.exception(f"[JOB {job_id}] failed: {exc}")
|
|
|
|
| 18 |
logger,
|
| 19 |
utc_now_iso,
|
| 20 |
)
|
| 21 |
+
from services.gradio_client_service import is_gradio_enabled, segment_via_gradio_sync
|
| 22 |
from services.sam2_service import (
|
| 23 |
jobs,
|
| 24 |
jobs_lock,
|
| 25 |
release_resources,
|
| 26 |
)
|
| 27 |
|
|
|
|
| 28 |
# Imported lazily to avoid circular imports
|
| 29 |
def _get_generate_label_map():
|
| 30 |
from services.scene_service import generate_label_map
|
|
|
|
| 134 |
logger.info(f"[JOB {job_id}] segmenting_with_sam2 progress=30 estimated_seconds={estimated_seconds}")
|
| 135 |
|
| 136 |
image_path = UPLOAD_DIR / safe_name
|
| 137 |
+
if is_gradio_enabled():
|
| 138 |
+
label_map, mask_count = segment_via_gradio_sync(image_path)
|
| 139 |
+
else:
|
| 140 |
+
generate_label_map = _get_generate_label_map()
|
| 141 |
+
label_map, mask_count = generate_label_map(image_rgb)
|
| 142 |
+
|
| 143 |
+
with jobs_lock:
|
| 144 |
+
job = jobs.setdefault(job_id, {})
|
| 145 |
+
job.update({
|
| 146 |
+
"stage": "saving_masks",
|
| 147 |
+
"progress": 92,
|
| 148 |
+
"message": "Saving mask map",
|
| 149 |
+
"updated_at": utc_now_iso(),
|
| 150 |
+
})
|
| 151 |
+
logger.info(f"[JOB {job_id}] saving_masks progress=92")
|
| 152 |
+
|
| 153 |
+
masks_dir = UPLOAD_DIR / "masks"
|
| 154 |
+
masks_dir.mkdir(exist_ok=True)
|
| 155 |
+
label_path = masks_dir / f"{safe_name}_labels.png"
|
| 156 |
+
if not cv2.imwrite(str(label_path), label_map):
|
| 157 |
+
raise HTTPException(status_code=500, detail="Failed to save label map")
|
| 158 |
+
|
| 159 |
+
# Classify each segment and save metadata
|
| 160 |
+
try:
|
| 161 |
+
from services.scene_service import classify_all_label_map_segments
|
| 162 |
+
h, w = image_rgb.shape[:2]
|
| 163 |
+
segments_meta = classify_all_label_map_segments(label_map, w, h, image_rgb)
|
| 164 |
+
meta_path = masks_dir / f"{safe_name}_labels_meta.json"
|
| 165 |
+
meta_path.write_text(
|
| 166 |
+
json.dumps({"segments": segments_meta}, ensure_ascii=False),
|
| 167 |
+
encoding="utf-8",
|
| 168 |
+
)
|
| 169 |
+
logger.info(f"[JOB {job_id}] segments_meta saved ({len(segments_meta)} segments)")
|
| 170 |
+
except Exception:
|
| 171 |
+
logger.exception(f"[JOB {job_id}] Failed to save segment metadata")
|
| 172 |
+
|
| 173 |
result: dict[str, Any] = {
|
| 174 |
"message": "Image uploaded successfully",
|
| 175 |
"filename": safe_name,
|
| 176 |
"url": f"/seg/image/{safe_name}",
|
| 177 |
+
"mask_count": mask_count,
|
| 178 |
}
|
| 179 |
|
| 180 |
with jobs_lock:
|
|
|
|
| 183 |
"status": "done",
|
| 184 |
"stage": "done",
|
| 185 |
"progress": 100,
|
| 186 |
+
"message": "Segmentation complete",
|
| 187 |
"result": result,
|
| 188 |
"updated_at": utc_now_iso(),
|
| 189 |
}
|
| 190 |
+
logger.info(f"[JOB {job_id}] done mask_count={mask_count}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
except Exception as exc:
|
| 193 |
logger.exception(f"[JOB {job_id}] failed: {exc}")
|
backend/services/inpainting_service.py
CHANGED
|
@@ -1,220 +1,28 @@
|
|
| 1 |
-
import io
|
| 2 |
-
import os
|
| 3 |
-
import base64
|
| 4 |
-
import openai
|
| 5 |
-
from pathlib import Path
|
| 6 |
-
from PIL import Image, ImageOps
|
| 7 |
from typing import Any
|
| 8 |
-
import numpy as np
|
| 9 |
|
| 10 |
-
from core.config import
|
| 11 |
-
from services.openai_service import _get_texture_hex
|
| 12 |
from models.schemas import ApplyTextureAIRequest
|
| 13 |
from services.sam2_service import jobs, jobs_lock
|
| 14 |
|
| 15 |
-
# ─── Descripciones en inglés para el prompt de DALL-E ─────────────────────────
|
| 16 |
-
TEXTURE_DESCRIPTIONS = {
|
| 17 |
-
"ACM_Amarillo": "bright yellow smooth aluminum composite panel exterior cladding",
|
| 18 |
-
"ACM_Azul": "blue aluminum composite panel exterior cladding",
|
| 19 |
-
"ACM_Glossy_Black": "glossy black aluminum composite panel exterior cladding",
|
| 20 |
-
"ACM_Glossy_Red": "glossy red aluminum composite panel exterior cladding",
|
| 21 |
-
"ACM_Grafito": "graphite dark grey aluminum composite panel exterior cladding",
|
| 22 |
-
"ACM_Light_Blue": "light sky blue aluminum composite panel exterior cladding",
|
| 23 |
-
"ACM_Madera_Clara": "light wood grain aluminum composite panel exterior cladding",
|
| 24 |
-
"ACM_Matteblack": "matte black aluminum composite panel exterior cladding",
|
| 25 |
-
"ACM_Metalic": "metallic silver brushed aluminum composite panel exterior cladding",
|
| 26 |
-
"ACM_MouseGrey": "mouse grey aluminum composite panel exterior cladding",
|
| 27 |
-
"ACM_Orange": "orange aluminum composite panel exterior cladding",
|
| 28 |
-
"ACM_ROBLE(OAK)": "oak wood grain aluminum composite panel exterior cladding",
|
| 29 |
-
"ACM_Verde": "green aluminum composite panel exterior cladding",
|
| 30 |
-
"ACM_Verde_HN": "dark forest green aluminum composite panel exterior cladding",
|
| 31 |
-
"ACM_Verde_Lima": "lime yellow-green aluminum composite panel exterior cladding",
|
| 32 |
-
"ACM_White": "white aluminum composite panel exterior cladding",
|
| 33 |
-
"DECK_gris": "grey WPC wood-plastic composite deck boards",
|
| 34 |
-
"DECK_madera": "natural wood-look WPC composite deck boards",
|
| 35 |
-
"DECK_madera_oscuro": "dark brown WPC composite deck boards",
|
| 36 |
-
"WPC_madera_claro": "light beige wood-grain WPC exterior wall cladding",
|
| 37 |
-
"WPC_madera_gris": "grey weathered wood-look WPC exterior wall cladding",
|
| 38 |
-
"WPC_madera_oscuro": "dark espresso wood-grain WPC exterior wall cladding",
|
| 39 |
-
"WPC_negro": "black WPC exterior wall cladding",
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
def prepare_image_square(pil_img, size=1024):
|
| 43 |
-
"""
|
| 44 |
-
Ajusta la imagen a un cuadrado de 1024x1024 añadiendo padding
|
| 45 |
-
para no deformar la imagen original.
|
| 46 |
-
"""
|
| 47 |
-
orig_w, orig_h = pil_img.size
|
| 48 |
-
ratio = orig_w / orig_h
|
| 49 |
-
|
| 50 |
-
if ratio > 1: # Es más ancha que alta (Horizontal)
|
| 51 |
-
new_w = size
|
| 52 |
-
new_h = int(size / ratio)
|
| 53 |
-
padding = (0, (size - new_h) // 2)
|
| 54 |
-
else: # Es más alta que ancha (Vertical)
|
| 55 |
-
new_h = size
|
| 56 |
-
new_w = int(size * ratio)
|
| 57 |
-
padding = ((size - new_w) // 2, 0)
|
| 58 |
-
|
| 59 |
-
# Redimensionar manteniendo proporción
|
| 60 |
-
resized_img = pil_img.resize((new_w, new_h), Image.LANCZOS)
|
| 61 |
-
|
| 62 |
-
# Crear fondo cuadrado con transparencia
|
| 63 |
-
new_img = Image.new("RGBA", (size, size), (255, 255, 255, 0))
|
| 64 |
-
new_img.paste(resized_img, padding)
|
| 65 |
-
|
| 66 |
-
return new_img, padding, (new_w, new_h)
|
| 67 |
|
| 68 |
def run_inpainting_sync(payload: ApplyTextureAIRequest) -> dict[str, Any]:
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
return {"error": "OpenAI API Key not found in environment"}
|
| 76 |
-
|
| 77 |
-
safe_name = Path(payload.filename).name
|
| 78 |
-
image_path = UPLOAD_DIR / safe_name
|
| 79 |
-
if not image_path.exists():
|
| 80 |
-
image_path = OUTPUT_DIR / safe_name
|
| 81 |
-
|
| 82 |
-
if not image_path.exists():
|
| 83 |
-
return {"error": f"Image not found: {payload.filename}"}
|
| 84 |
-
|
| 85 |
-
# Determinar descripción de textura
|
| 86 |
-
texture_name = payload.texture_name or "ACM_White"
|
| 87 |
-
texture_stem = Path(texture_name).stem
|
| 88 |
-
texture_desc = TEXTURE_DESCRIPTIONS.get(texture_stem, texture_name)
|
| 89 |
-
|
| 90 |
-
# Specs técnicas para el prompt
|
| 91 |
-
acm_specs = ""
|
| 92 |
-
if texture_stem.startswith("ACM"):
|
| 93 |
-
acm_specs = (
|
| 94 |
-
"ACM aluminum composite panel 4mm thick, 0.40mm aluminum layers, "
|
| 95 |
-
"panels sized 1.22m x 2.44m, clean precision-cut joints between panels, "
|
| 96 |
-
)
|
| 97 |
-
elif "WPC" in texture_stem or "DECK" in texture_stem:
|
| 98 |
-
acm_specs = "WPC wood-plastic composite profile boards, "
|
| 99 |
-
|
| 100 |
-
try:
|
| 101 |
-
client = openai.OpenAI(api_key=api_key)
|
| 102 |
-
|
| 103 |
-
pil_img_orig = Image.open(str(image_path)).convert("RGBA")
|
| 104 |
-
orig_size = pil_img_orig.size
|
| 105 |
-
|
| 106 |
-
square_img, padding, resized_dims = prepare_image_square(pil_img_orig)
|
| 107 |
-
|
| 108 |
-
buf = io.BytesIO()
|
| 109 |
-
square_img.save(buf, format="PNG")
|
| 110 |
-
buf.seek(0)
|
| 111 |
-
|
| 112 |
-
# Usar el prompt enviado o generar uno por defecto
|
| 113 |
-
prompt = payload.prompt or (
|
| 114 |
-
f"Edit ONLY the exterior wall cladding material of this house. "
|
| 115 |
-
f"Replace all facade wall surfaces with {acm_specs}{texture_desc}. "
|
| 116 |
-
f"Keep EVERYTHING ELSE exactly the same: the house structure, shape, proportions, "
|
| 117 |
-
f"roof, windows, doors, garage, balcony railings, garden, plants, sky and lighting. "
|
| 118 |
-
f"Only change the wall surface material. "
|
| 119 |
-
f"Result must look like a photorealistic architectural photo. "
|
| 120 |
-
f"CRITICAL: Do NOT stylize, do NOT apply artistic filters, do NOT add painterly or CGI effects. "
|
| 121 |
-
f"Preserve photographic grain, camera exposure, highlights and shadows, and match original lighting and perspective. "
|
| 122 |
-
f"Avoid oversmoothing — keep realistic texture detail and crisp panel edges."
|
| 123 |
-
)
|
| 124 |
-
|
| 125 |
-
# If texture file exists, compute representative hex and request exact color match
|
| 126 |
-
tex_hex = _get_texture_hex(texture_name or "") if texture_name else None
|
| 127 |
-
if tex_hex:
|
| 128 |
-
prompt += f" Use the exact color sample {tex_hex} from the reference texture and match its hue, saturation and brightness precisely. Do not alter the color tone."
|
| 129 |
|
| 130 |
-
# gpt-image-1 solo acepta 1024x1024
|
| 131 |
-
# If a texture file exists, attach it as a reference image when possible
|
| 132 |
-
tex_path = None
|
| 133 |
-
if texture_name:
|
| 134 |
-
try:
|
| 135 |
-
from services.texture_service import resolve_texture_path as _resolve
|
| 136 |
-
tex_path = _resolve(texture_name)
|
| 137 |
-
except Exception:
|
| 138 |
-
tex_path = None
|
| 139 |
-
|
| 140 |
-
if tex_path:
|
| 141 |
-
try:
|
| 142 |
-
with open(tex_path, "rb") as tf:
|
| 143 |
-
tex_buf = io.BytesIO(tf.read())
|
| 144 |
-
tex_buf.seek(0)
|
| 145 |
-
response = client.images.edit(
|
| 146 |
-
model="gpt-image-1",
|
| 147 |
-
image=("house.png", buf, "image/png"),
|
| 148 |
-
reference_image=(Path(tex_path).name, tex_buf, "image/png"),
|
| 149 |
-
prompt=prompt,
|
| 150 |
-
n=1,
|
| 151 |
-
size="1024x1024",
|
| 152 |
-
response_format="b64_json",
|
| 153 |
-
)
|
| 154 |
-
except Exception:
|
| 155 |
-
response = client.images.edit(
|
| 156 |
-
model="gpt-image-1",
|
| 157 |
-
image=("house.png", buf, "image/png"),
|
| 158 |
-
prompt=prompt,
|
| 159 |
-
n=1,
|
| 160 |
-
size="1024x1024",
|
| 161 |
-
response_format="b64_json",
|
| 162 |
-
)
|
| 163 |
-
else:
|
| 164 |
-
response = client.images.edit(
|
| 165 |
-
model="gpt-image-1",
|
| 166 |
-
image=("house.png", buf, "image/png"),
|
| 167 |
-
prompt=prompt,
|
| 168 |
-
n=1,
|
| 169 |
-
size="1024x1024",
|
| 170 |
-
response_format="b64_json",
|
| 171 |
-
)
|
| 172 |
-
|
| 173 |
-
img_data = base64.b64decode(response.data[0].b64_json)
|
| 174 |
-
result_square = Image.open(io.BytesIO(img_data)).convert("RGBA")
|
| 175 |
-
|
| 176 |
-
# 2. Recortar el resultado para volver al aspecto original (quitar padding)
|
| 177 |
-
left, top = padding
|
| 178 |
-
right, bottom = left + resized_dims[0], top + resized_dims[1]
|
| 179 |
-
result_cropped = result_square.crop((left, top, right, bottom))
|
| 180 |
-
|
| 181 |
-
# 3. Redimensionar al tamaño original exacto
|
| 182 |
-
result = result_cropped.resize(orig_size, Image.LANCZOS).convert("RGB")
|
| 183 |
-
|
| 184 |
-
# Return generated result without blending with original image
|
| 185 |
-
final = result
|
| 186 |
-
|
| 187 |
-
out_filename = f"{Path(safe_name).stem}_ai_{texture_stem}.jpg"
|
| 188 |
-
out_path = OUTPUT_DIR / out_filename
|
| 189 |
-
final.save(str(out_path), "JPEG", quality=90)
|
| 190 |
-
|
| 191 |
-
return {
|
| 192 |
-
"message": f"AI Texture applied: {texture_name}",
|
| 193 |
-
"filename": out_filename,
|
| 194 |
-
"url": f"/seg/ai/{out_filename}",
|
| 195 |
-
"processing": False
|
| 196 |
-
}
|
| 197 |
-
|
| 198 |
-
except Exception as e:
|
| 199 |
-
logger.error(f"DALL-E Error: {e}")
|
| 200 |
-
return {"error": str(e)}
|
| 201 |
|
| 202 |
def run_inpainting_job(job_id: str, payload: ApplyTextureAIRequest) -> None:
|
| 203 |
try:
|
| 204 |
result = run_inpainting_sync(payload)
|
| 205 |
with jobs_lock:
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
}
|
| 212 |
-
else:
|
| 213 |
-
jobs[job_id] = {
|
| 214 |
-
"status": "done",
|
| 215 |
-
"result": result,
|
| 216 |
-
"updated_at": utc_now_iso(),
|
| 217 |
-
}
|
| 218 |
except Exception as exc:
|
| 219 |
with jobs_lock:
|
| 220 |
jobs[job_id] = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from typing import Any
|
|
|
|
| 2 |
|
| 3 |
+
from core.config import utc_now_iso
|
|
|
|
| 4 |
from models.schemas import ApplyTextureAIRequest
|
| 5 |
from services.sam2_service import jobs, jobs_lock
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
def run_inpainting_sync(payload: ApplyTextureAIRequest) -> dict[str, Any]:
|
| 9 |
+
return {
|
| 10 |
+
"message": "Inpainting not configured",
|
| 11 |
+
"filename": payload.filename,
|
| 12 |
+
"prompt": payload.prompt,
|
| 13 |
+
"processing": False,
|
| 14 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
def run_inpainting_job(job_id: str, payload: ApplyTextureAIRequest) -> None:
|
| 18 |
try:
|
| 19 |
result = run_inpainting_sync(payload)
|
| 20 |
with jobs_lock:
|
| 21 |
+
jobs[job_id] = {
|
| 22 |
+
"status": "done",
|
| 23 |
+
"result": result,
|
| 24 |
+
"updated_at": utc_now_iso(),
|
| 25 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
except Exception as exc:
|
| 27 |
with jobs_lock:
|
| 28 |
jobs[job_id] = {
|
backend/services/sam2_service.py
CHANGED
|
@@ -112,9 +112,6 @@ def find_sam2_model_path() -> Path:
|
|
| 112 |
|
| 113 |
def load_sam2_model() -> None:
|
| 114 |
global sam2_mask_generator, sam2_image_predictor, sam2_load_error
|
| 115 |
-
sam2_load_error = "SAM2 disabled (using Simplified OpenAI flow)"
|
| 116 |
-
logger.info(f"[SAM2] {sam2_load_error}")
|
| 117 |
-
return
|
| 118 |
|
| 119 |
if not _TORCH_AVAILABLE:
|
| 120 |
sam2_load_error = "torch not installed — SAM2 unavailable (using Gradio Space)"
|
|
|
|
| 112 |
|
| 113 |
def load_sam2_model() -> None:
|
| 114 |
global sam2_mask_generator, sam2_image_predictor, sam2_load_error
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
if not _TORCH_AVAILABLE:
|
| 117 |
sam2_load_error = "torch not installed — SAM2 unavailable (using Gradio Space)"
|
backend/services/texture_service.py
CHANGED
|
@@ -1,851 +1,851 @@
|
|
| 1 |
-
import io
|
| 2 |
-
import shutil
|
| 3 |
-
import uuid
|
| 4 |
-
from pathlib import Path
|
| 5 |
-
from typing import Any
|
| 6 |
-
|
| 7 |
-
import cv2
|
| 8 |
-
import numpy as np
|
| 9 |
-
from fastapi import HTTPException
|
| 10 |
-
from PIL import Image
|
| 11 |
-
|
| 12 |
-
from core.config import (
|
| 13 |
-
OUTPUT_DIR,
|
| 14 |
-
TEXTURE_DIR,
|
| 15 |
-
UPLOAD_DIR,
|
| 16 |
-
UPLOAD_JPEG_QUALITY,
|
| 17 |
-
log_timing_end,
|
| 18 |
-
log_timing_start,
|
| 19 |
-
logger,
|
| 20 |
-
)
|
| 21 |
-
from models.schemas import ApplyTextureRequest
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def generate_texture_variations(texture_name: str) -> list[dict[str, str]]:
|
| 25 |
-
"""
|
| 26 |
-
Genera variaciones de color/brillo/saturación de una textura de referencia usando HSV.
|
| 27 |
-
Los archivos se cachean en TEXTURE_DIR/generated/ — si ya existen no se regeneran.
|
| 28 |
-
Devuelve lista de {ref, label, preview_url}.
|
| 29 |
-
"""
|
| 30 |
-
texture_path = resolve_texture_path(texture_name)
|
| 31 |
-
generated_dir = TEXTURE_DIR / "generated"
|
| 32 |
-
generated_dir.mkdir(parents=True, exist_ok=True)
|
| 33 |
-
|
| 34 |
-
tex_pil = load_texture_pil_rgb(texture_path)
|
| 35 |
-
tex_bgr = cv2.cvtColor(np.array(tex_pil, dtype=np.uint8), cv2.COLOR_RGB2BGR)
|
| 36 |
-
tex_hsv = cv2.cvtColor(tex_bgr, cv2.COLOR_BGR2HSV).astype(np.int32)
|
| 37 |
-
|
| 38 |
-
base_stem = Path(texture_name).stem
|
| 39 |
-
results: list[dict[str, str]] = []
|
| 40 |
-
|
| 41 |
-
def _save(hsv: np.ndarray, suffix: str, label: str) -> None:
|
| 42 |
-
fname = f"{base_stem}__{suffix}.jpg"
|
| 43 |
-
out_path = generated_dir / fname
|
| 44 |
-
if not out_path.exists():
|
| 45 |
-
bgr = cv2.cvtColor(np.clip(hsv, 0, 255).astype(np.uint8), cv2.COLOR_HSV2BGR)
|
| 46 |
-
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
| 47 |
-
Image.fromarray(rgb).save(str(out_path), format="JPEG", quality=92, optimize=True)
|
| 48 |
-
ref = f"generated/{fname}"
|
| 49 |
-
results.append({"ref": ref, "label": label, "preview_url": f"/seg/texture-preview/{ref}"})
|
| 50 |
-
|
| 51 |
-
# Rotaciones de tono: 12 pasos de 30° recorriendo el círculo cromático
|
| 52 |
-
# En OpenCV HSV, H ∈ [0,179] → shift en grados / 2
|
| 53 |
-
for deg, label in [
|
| 54 |
-
(30, "Naranja"),
|
| 55 |
-
(60, "Amarillo"),
|
| 56 |
-
(90, "Verde lima"),
|
| 57 |
-
(120, "Verde"),
|
| 58 |
-
(150, "Verde agua"),
|
| 59 |
-
(165, "Cyan"),
|
| 60 |
-
(180, "Azul cielo"),
|
| 61 |
-
(210, "Azul"),
|
| 62 |
-
(240, "Índigo"),
|
| 63 |
-
(270, "Violeta"),
|
| 64 |
-
(300, "Magenta"),
|
| 65 |
-
(330, "Rosa"),
|
| 66 |
-
]:
|
| 67 |
-
v = tex_hsv.copy()
|
| 68 |
-
v[:, :, 0] = (v[:, :, 0] + deg // 2) % 180
|
| 69 |
-
_save(v, f"hue{deg}", label)
|
| 70 |
-
|
| 71 |
-
# Variaciones de brillo
|
| 72 |
-
for factor, label, suffix in [
|
| 73 |
-
(0.45, "Oscuro", "dark"),
|
| 74 |
-
(1.55, "Claro", "light"),
|
| 75 |
-
]:
|
| 76 |
-
v = tex_hsv.copy()
|
| 77 |
-
v[:, :, 2] = np.clip(v[:, :, 2] * factor, 0, 255)
|
| 78 |
-
_save(v, suffix, label)
|
| 79 |
-
|
| 80 |
-
# Variaciones de saturación
|
| 81 |
-
for factor, label, suffix in [
|
| 82 |
-
(0.0, "Gris", "gray"),
|
| 83 |
-
(0.45, "Apagado", "muted"),
|
| 84 |
-
(1.75, "Vívido", "vivid"),
|
| 85 |
-
]:
|
| 86 |
-
v = tex_hsv.copy()
|
| 87 |
-
v[:, :, 1] = np.clip(v[:, :, 1] * factor, 0, 255)
|
| 88 |
-
_save(v, suffix, label)
|
| 89 |
-
|
| 90 |
-
return results
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def list_available_textures() -> list[str]:
|
| 94 |
-
allowed = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff", ".exr"}
|
| 95 |
-
return [
|
| 96 |
-
str(path.relative_to(TEXTURE_DIR)).replace("\\", "/")
|
| 97 |
-
for path in sorted(TEXTURE_DIR.rglob("*"))
|
| 98 |
-
if path.is_file() and path.suffix.lower() in allowed
|
| 99 |
-
]
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def resolve_texture_path(texture_name: str) -> Path:
|
| 103 |
-
if not texture_name:
|
| 104 |
-
raise HTTPException(status_code=400, detail="Invalid texture_name")
|
| 105 |
-
|
| 106 |
-
normalized = texture_name.replace("\\", "/").strip("/")
|
| 107 |
-
candidate = (TEXTURE_DIR / normalized).resolve()
|
| 108 |
-
base = TEXTURE_DIR.resolve()
|
| 109 |
-
|
| 110 |
-
try:
|
| 111 |
-
candidate.relative_to(base)
|
| 112 |
-
except ValueError as exc:
|
| 113 |
-
raise HTTPException(status_code=400, detail="Invalid texture_name") from exc
|
| 114 |
-
|
| 115 |
-
if not candidate.exists() or not candidate.is_file():
|
| 116 |
-
raise HTTPException(status_code=404, detail=f"Texture not found: {normalized}")
|
| 117 |
-
|
| 118 |
-
return candidate
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
def build_texture_preview_jpeg(texture_path: Path, max_size: int = 320) -> bytes:
|
| 122 |
-
pil_img = load_texture_pil_rgb(texture_path)
|
| 123 |
-
pil_img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
|
| 124 |
-
out = io.BytesIO()
|
| 125 |
-
pil_img.save(out, format="JPEG", quality=88, optimize=True)
|
| 126 |
-
return out.getvalue()
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
def load_texture_pil_rgb(texture_path: Path) -> Image.Image:
|
| 130 |
-
suffix = texture_path.suffix.lower()
|
| 131 |
-
|
| 132 |
-
if suffix != ".exr":
|
| 133 |
-
try:
|
| 134 |
-
return Image.open(str(texture_path)).convert("RGB")
|
| 135 |
-
except Exception as exc:
|
| 136 |
-
raise HTTPException(status_code=500, detail=f"Could not read texture file: {exc}") from exc
|
| 137 |
-
|
| 138 |
-
exr = cv2.imread(str(texture_path), cv2.IMREAD_UNCHANGED)
|
| 139 |
-
if exr is None:
|
| 140 |
-
raise HTTPException(status_code=500, detail="Could not decode EXR texture")
|
| 141 |
-
|
| 142 |
-
if exr.ndim == 2:
|
| 143 |
-
exr = np.stack([exr, exr, exr], axis=-1)
|
| 144 |
-
if exr.ndim != 3:
|
| 145 |
-
raise HTTPException(status_code=500, detail="EXR texture has unsupported shape")
|
| 146 |
-
if exr.shape[2] > 3:
|
| 147 |
-
exr = exr[:, :, :3]
|
| 148 |
-
|
| 149 |
-
exr = np.nan_to_num(exr, nan=0.0, posinf=0.0, neginf=0.0)
|
| 150 |
-
exr = np.maximum(exr, 0)
|
| 151 |
-
|
| 152 |
-
if np.issubdtype(exr.dtype, np.floating):
|
| 153 |
-
scale = float(np.percentile(exr, 99.0))
|
| 154 |
-
if scale <= 1e-8:
|
| 155 |
-
scale = float(np.max(exr))
|
| 156 |
-
if scale <= 1e-8:
|
| 157 |
-
scale = 1.0
|
| 158 |
-
img = np.clip(exr / scale, 0.0, 1.0)
|
| 159 |
-
img = np.power(img, 1.0 / 2.2)
|
| 160 |
-
img_u8 = (img * 255.0).astype(np.uint8)
|
| 161 |
-
elif exr.dtype == np.uint16:
|
| 162 |
-
img_u8 = (exr / 257.0).astype(np.uint8)
|
| 163 |
-
else:
|
| 164 |
-
img_u8 = np.clip(exr, 0, 255).astype(np.uint8)
|
| 165 |
-
|
| 166 |
-
img_rgb = cv2.cvtColor(img_u8, cv2.COLOR_BGR2RGB)
|
| 167 |
-
return Image.fromarray(img_rgb).convert("RGB")
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
def estimate_mask_orientation_degrees(binary_mask: np.ndarray) -> float:
|
| 171 |
-
mask_u8 = (binary_mask > 0).astype(np.uint8)
|
| 172 |
-
contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 173 |
-
if not contours:
|
| 174 |
-
return 0.0
|
| 175 |
-
|
| 176 |
-
largest = max(contours, key=cv2.contourArea)
|
| 177 |
-
if cv2.contourArea(largest) < 25.0:
|
| 178 |
-
return 0.0
|
| 179 |
-
|
| 180 |
-
rect = cv2.minAreaRect(largest)
|
| 181 |
-
(_, _), (width, height), angle = rect
|
| 182 |
-
|
| 183 |
-
dominant_angle = float(angle)
|
| 184 |
-
if width < height:
|
| 185 |
-
dominant_angle += 90.0
|
| 186 |
-
|
| 187 |
-
dominant_angle %= 180.0
|
| 188 |
-
return dominant_angle
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
def _compute_trapezoid_score_from_mask(
|
| 192 |
-
binary_mask: np.ndarray,
|
| 193 |
-
ys: np.ndarray,
|
| 194 |
-
xs: np.ndarray,
|
| 195 |
-
min_y: int,
|
| 196 |
-
max_y: int,
|
| 197 |
-
bbox_h: int,
|
| 198 |
-
) -> float:
|
| 199 |
-
"""Return 0..1 indicating how floor-like (wider at bottom) the mask shape is."""
|
| 200 |
-
quarter = max(1, bbox_h // 4)
|
| 201 |
-
top_xs = xs[ys <= (min_y + quarter)]
|
| 202 |
-
bot_xs = xs[ys >= (max_y - quarter)]
|
| 203 |
-
if len(top_xs) < 3 or len(bot_xs) < 3:
|
| 204 |
-
return 0.0
|
| 205 |
-
top_w = float(top_xs.max() - top_xs.min())
|
| 206 |
-
bot_w = float(bot_xs.max() - bot_xs.min())
|
| 207 |
-
if top_w < 5.0:
|
| 208 |
-
return 1.0 if bot_w > 20.0 else 0.0
|
| 209 |
-
ratio = bot_w / top_w
|
| 210 |
-
return float(np.clip((ratio - 1.0) / 1.8, 0.0, 1.0))
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
def _sort_quad_corners(pts: np.ndarray) -> np.ndarray:
|
| 214 |
-
"""Sort 4 points into [TL, TR, BR, BL] order."""
|
| 215 |
-
result = np.zeros((4, 2), dtype=np.float32)
|
| 216 |
-
s = pts[:, 0] + pts[:, 1]
|
| 217 |
-
d = pts[:, 0] - pts[:, 1]
|
| 218 |
-
result[0] = pts[np.argmin(s)]
|
| 219 |
-
result[1] = pts[np.argmax(d)]
|
| 220 |
-
result[2] = pts[np.argmax(s)]
|
| 221 |
-
result[3] = pts[np.argmin(d)]
|
| 222 |
-
return result
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
def _extract_mask_quad(binary_mask: np.ndarray) -> np.ndarray | None:
|
| 226 |
-
"""Approximate mask as 4-corner polygon sorted [TL, TR, BR, BL], or None."""
|
| 227 |
-
mask_u8 = (binary_mask > 0).astype(np.uint8)
|
| 228 |
-
contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 229 |
-
if not contours:
|
| 230 |
-
return None
|
| 231 |
-
largest = max(contours, key=cv2.contourArea)
|
| 232 |
-
if cv2.contourArea(largest) < 400.0:
|
| 233 |
-
return None
|
| 234 |
-
hull = cv2.convexHull(largest)
|
| 235 |
-
peri = cv2.arcLength(hull, True)
|
| 236 |
-
for eps_frac in (0.03, 0.05, 0.08, 0.10, 0.13):
|
| 237 |
-
approx = cv2.approxPolyDP(hull, eps_frac * peri, True)
|
| 238 |
-
if len(approx) == 4:
|
| 239 |
-
return _sort_quad_corners(approx.reshape(4, 2).astype(np.float32))
|
| 240 |
-
return None
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
def _tile_texture_perspective(
|
| 244 |
-
tex_pil: Image.Image,
|
| 245 |
-
quad: np.ndarray,
|
| 246 |
-
image_width: int,
|
| 247 |
-
image_height: int,
|
| 248 |
-
) -> Image.Image | None:
|
| 249 |
-
"""
|
| 250 |
-
Tile texture with perspective correction for a floor surface.
|
| 251 |
-
quad: [TL, TR, BR, BL] in image coordinates.
|
| 252 |
-
Returns full-image-size PIL image with warped tiled texture, or None on failure.
|
| 253 |
-
Pixels outside the perspective quad are filled with regular tiling to avoid black gaps.
|
| 254 |
-
"""
|
| 255 |
-
tl, tr, br, bl = quad
|
| 256 |
-
bot_w = float(np.linalg.norm(br.astype(float) - bl.astype(float)))
|
| 257 |
-
top_w = float(np.linalg.norm(tr.astype(float) - tl.astype(float)))
|
| 258 |
-
left_h = float(np.linalg.norm(bl.astype(float) - tl.astype(float)))
|
| 259 |
-
right_h = float(np.linalg.norm(br.astype(float) - tr.astype(float)))
|
| 260 |
-
rect_w = min(int(max(bot_w, top_w)) + 1, image_width * 2)
|
| 261 |
-
rect_h = min(int(max(left_h, right_h)) + 1, image_height * 2)
|
| 262 |
-
if rect_w < 8 or rect_h < 8:
|
| 263 |
-
return None
|
| 264 |
-
tex_arr = np.array(tex_pil.convert("RGB"), dtype=np.uint8)
|
| 265 |
-
th, tw = tex_arr.shape[:2]
|
| 266 |
-
if tw < 1 or th < 1:
|
| 267 |
-
return None
|
| 268 |
-
rect_tiled = np.zeros((rect_h, rect_w, 3), dtype=np.uint8)
|
| 269 |
-
for ry in range(0, rect_h, th):
|
| 270 |
-
for rx in range(0, rect_w, tw):
|
| 271 |
-
py = min(th, rect_h - ry)
|
| 272 |
-
px = min(tw, rect_w - rx)
|
| 273 |
-
rect_tiled[ry : ry + py, rx : rx + px] = tex_arr[:py, :px]
|
| 274 |
-
src_pts = np.array(
|
| 275 |
-
[[0.0, 0.0], [float(rect_w - 1), 0.0], [float(rect_w - 1), float(rect_h - 1)], [0.0, float(rect_h - 1)]],
|
| 276 |
-
dtype=np.float32,
|
| 277 |
-
)
|
| 278 |
-
dst_pts = quad.astype(np.float32)
|
| 279 |
-
try:
|
| 280 |
-
H = cv2.getPerspectiveTransform(src_pts, dst_pts)
|
| 281 |
-
warped = cv2.warpPerspective(rect_tiled, H, (image_width, image_height))
|
| 282 |
-
# Mapa de cobertura: píxeles realmente cubiertos por el warp
|
| 283 |
-
cov_src = np.ones((rect_h, rect_w), dtype=np.uint8) * 255
|
| 284 |
-
coverage = cv2.warpPerspective(cov_src, H, (image_width, image_height))
|
| 285 |
-
except cv2.error:
|
| 286 |
-
return None
|
| 287 |
-
|
| 288 |
-
# Rellenar píxeles sin cobertura (fuera del quad) con tiling regular
|
| 289 |
-
# para evitar espacios negros donde la máscara supera el quad aproximado
|
| 290 |
-
regular = np.zeros((image_height, image_width, 3), dtype=np.uint8)
|
| 291 |
-
for ry in range(0, image_height, th):
|
| 292 |
-
for rx in range(0, image_width, tw):
|
| 293 |
-
py = min(th, image_height - ry)
|
| 294 |
-
px = min(tw, image_width - rx)
|
| 295 |
-
regular[ry : ry + py, rx : rx + px] = tex_arr[:py, :px]
|
| 296 |
-
|
| 297 |
-
uncovered = coverage < 128
|
| 298 |
-
warped[uncovered] = regular[uncovered]
|
| 299 |
-
|
| 300 |
-
return Image.fromarray(warped)
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
def classify_texture_material(texture_name: str) -> str:
|
| 304 |
-
texture_key = texture_name.lower()
|
| 305 |
-
if "acm" in texture_key or "wpc" in texture_key:
|
| 306 |
-
return "acm"
|
| 307 |
-
if any(hint in texture_key for hint in ("deck", "wood", "plank", "laminate", "floor")):
|
| 308 |
-
return "wood"
|
| 309 |
-
if any(hint in texture_key for hint in ("marble", "granite", "tile", "brick", "cobblestone", "stone", "cartago", "riverbed")):
|
| 310 |
-
return "stone"
|
| 311 |
-
if any(hint in texture_key for hint in ("metal", "rust", "iron", "steel")):
|
| 312 |
-
return "metal"
|
| 313 |
-
return "generic"
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
def infer_surface_type_and_direction(
|
| 317 |
-
binary_mask: np.ndarray,
|
| 318 |
-
image_width: int,
|
| 319 |
-
image_height: int,
|
| 320 |
-
texture_name: str,
|
| 321 |
-
) -> tuple[str, float, float, int]:
|
| 322 |
-
mask_u8 = (binary_mask > 0).astype(np.uint8)
|
| 323 |
-
ys, xs = np.where(mask_u8 > 0)
|
| 324 |
-
if ys.size == 0 or xs.size == 0:
|
| 325 |
-
return ("wall", 0.0, 0.78, max(180, image_width // 4))
|
| 326 |
-
|
| 327 |
-
min_x, max_x = int(xs.min()), int(xs.max())
|
| 328 |
-
min_y, max_y = int(ys.min()), int(ys.max())
|
| 329 |
-
bbox_w = max(1, max_x - min_x + 1)
|
| 330 |
-
bbox_h = max(1, max_y - min_y + 1)
|
| 331 |
-
aspect = bbox_w / max(1.0, float(bbox_h))
|
| 332 |
-
center_y = float(ys.mean()) / max(1.0, float(image_height))
|
| 333 |
-
dominant_angle = estimate_mask_orientation_degrees(binary_mask)
|
| 334 |
-
material = classify_texture_material(texture_name)
|
| 335 |
-
|
| 336 |
-
# Trapezoid score: floor in perspective is wider at the bottom than the top
|
| 337 |
-
trapezoid_score = _compute_trapezoid_score_from_mask(binary_mask, ys, xs, min_y, max_y, bbox_h)
|
| 338 |
-
|
| 339 |
-
is_ceiling = center_y < 0.26 and aspect > 1.35
|
| 340 |
-
# Floor: low center + trapezoidal shape, OR clearly low + wide
|
| 341 |
-
is_floor = (
|
| 342 |
-
(center_y > 0.55 and aspect >= 0.9 and trapezoid_score > 0.30)
|
| 343 |
-
or (center_y > 0.68 and aspect > 1.15)
|
| 344 |
-
)
|
| 345 |
-
|
| 346 |
-
if is_ceiling:
|
| 347 |
-
surface_type = "ceiling"
|
| 348 |
-
angle = 0.0
|
| 349 |
-
blend_alpha = 0.58
|
| 350 |
-
tile_width = max(128, image_width // 5)
|
| 351 |
-
elif is_floor:
|
| 352 |
-
if material == "wood":
|
| 353 |
-
surface_type = "deck"
|
| 354 |
-
angle = dominant_angle if 8.0 <= dominant_angle <= 172.0 else 0.0
|
| 355 |
-
blend_alpha = 0.82
|
| 356 |
-
tile_width = max(320, int(bbox_w * 0.95), image_width // 2)
|
| 357 |
-
else:
|
| 358 |
-
surface_type = "floor"
|
| 359 |
-
angle = 0.0
|
| 360 |
-
blend_alpha = 0.80
|
| 361 |
-
# ACM floor: ~3 large-format panels visible on the near edge
|
| 362 |
-
tile_width = max(200, int(bbox_w * 0.35)) if material == "acm" else max(144, image_width // 3)
|
| 363 |
-
else:
|
| 364 |
-
surface_type = "wall"
|
| 365 |
-
angle = 0.0
|
| 366 |
-
if material == "acm":
|
| 367 |
-
blend_alpha = 0.78
|
| 368 |
-
# ACM wall panels: ~3 panels across the surface width
|
| 369 |
-
tile_width = max(180, int(bbox_w * 0.33))
|
| 370 |
-
elif material == "wood":
|
| 371 |
-
blend_alpha = 0.70
|
| 372 |
-
tile_width = max(220, int(bbox_w * 0.55), image_width // 4)
|
| 373 |
-
elif material == "stone":
|
| 374 |
-
blend_alpha = 0.84
|
| 375 |
-
tile_width = max(128, image_width // 4)
|
| 376 |
-
else:
|
| 377 |
-
blend_alpha = 0.66
|
| 378 |
-
tile_width = max(128, image_width // 4)
|
| 379 |
-
|
| 380 |
-
return (surface_type, float(angle % 180.0), float(blend_alpha), int(tile_width))
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
def choose_auto_texture_settings(material: str, surface_type: str) -> tuple[float, float, float]:
|
| 384 |
-
strength_map = {"acm": 0.98, "stone": 0.96, "wood": 0.88, "metal": 0.91, "generic": 0.9}
|
| 385 |
-
intensity_map = {"acm": 0.08, "stone": 0.36, "wood": 0.3, "metal": 0.34, "generic": 0.32}
|
| 386 |
-
|
| 387 |
-
strength = float(strength_map.get(material, 0.9))
|
| 388 |
-
intensity = float(intensity_map.get(material, 0.32))
|
| 389 |
-
|
| 390 |
-
if surface_type in {"wall", "facade"}:
|
| 391 |
-
strength += 0.02
|
| 392 |
-
angle = 28.0
|
| 393 |
-
elif surface_type in {"roof"}:
|
| 394 |
-
angle = 42.0
|
| 395 |
-
intensity += 0.03
|
| 396 |
-
elif surface_type in {"floor", "deck"}:
|
| 397 |
-
angle = 24.0
|
| 398 |
-
intensity += 0.02
|
| 399 |
-
else:
|
| 400 |
-
angle = 35.0
|
| 401 |
-
|
| 402 |
-
return (
|
| 403 |
-
float(np.clip(strength, 0.55, 0.99)),
|
| 404 |
-
float(angle % 360.0),
|
| 405 |
-
float(np.clip(intensity, 0.0, 1.0)),
|
| 406 |
-
)
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
def build_feather_mask(binary_mask: np.ndarray, sigma: float = 2.2) -> np.ndarray:
|
| 410 |
-
mask = (binary_mask > 0).astype(np.float32)
|
| 411 |
-
if mask.max() <= 0:
|
| 412 |
-
return mask
|
| 413 |
-
feather = cv2.GaussianBlur(mask, (0, 0), sigmaX=sigma, sigmaY=sigma)
|
| 414 |
-
return np.clip(feather, 0.0, 1.0)
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
def build_scene_luminance_map(orig_rgb: np.ndarray) -> np.ndarray:
|
| 418 |
-
orig_u8 = orig_rgb.astype(np.uint8)
|
| 419 |
-
orig_lab = cv2.cvtColor(orig_u8, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 420 |
-
l_channel = orig_lab[:, :, 0] / 255.0
|
| 421 |
-
broad_light = cv2.GaussianBlur(l_channel, (0, 0), sigmaX=18.0, sigmaY=18.0)
|
| 422 |
-
local_detail = l_channel - cv2.GaussianBlur(l_channel, (0, 0), sigmaX=4.0, sigmaY=4.0)
|
| 423 |
-
light_map = 0.82 + (broad_light * 0.36) + (local_detail * 0.18)
|
| 424 |
-
return np.clip(light_map, 0.72, 1.22)
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
def build_texture_relief_map(tex_rgb: np.ndarray, material: str = "generic") -> np.ndarray:
|
| 428 |
-
tex_u8 = tex_rgb.astype(np.uint8)
|
| 429 |
-
tex_gray = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
|
| 430 |
-
micro_relief = tex_gray - cv2.GaussianBlur(tex_gray, (0, 0), sigmaX=3.0, sigmaY=3.0)
|
| 431 |
-
|
| 432 |
-
relief_scale = {"acm": 0.35, "stone": 2.8, "wood": 2.2, "metal": 1.8}.get(material, 2.0)
|
| 433 |
-
return np.clip(micro_relief * relief_scale, -1.0, 1.0)
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
def build_directional_light_map(
|
| 437 |
-
tex_rgb: np.ndarray,
|
| 438 |
-
material: str,
|
| 439 |
-
light_angle_degrees: float,
|
| 440 |
-
light_intensity: float,
|
| 441 |
-
) -> np.ndarray:
|
| 442 |
-
tex_u8 = tex_rgb.astype(np.uint8)
|
| 443 |
-
tex_gray = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
|
| 444 |
-
height_map = cv2.GaussianBlur(tex_gray, (0, 0), sigmaX=1.4, sigmaY=1.4)
|
| 445 |
-
grad_x = cv2.Sobel(height_map, cv2.CV_32F, 1, 0, ksize=3)
|
| 446 |
-
grad_y = cv2.Sobel(height_map, cv2.CV_32F, 0, 1, ksize=3)
|
| 447 |
-
|
| 448 |
-
relief_scale = {"acm": 0.45, "stone": 3.0, "wood": 2.4, "metal": 1.6}.get(material, 2.0)
|
| 449 |
-
|
| 450 |
-
nx = -grad_x * relief_scale
|
| 451 |
-
ny = -grad_y * relief_scale
|
| 452 |
-
nz = np.ones_like(nx, dtype=np.float32)
|
| 453 |
-
norm = np.sqrt((nx * nx) + (ny * ny) + (nz * nz)) + 1e-6
|
| 454 |
-
nx = nx / norm
|
| 455 |
-
ny = ny / norm
|
| 456 |
-
nz = nz / norm
|
| 457 |
-
|
| 458 |
-
theta = np.deg2rad(float(light_angle_degrees))
|
| 459 |
-
lx = float(np.cos(theta))
|
| 460 |
-
ly = float(-np.sin(theta))
|
| 461 |
-
lz = 0.82
|
| 462 |
-
light_norm = max(1e-6, float(np.sqrt((lx * lx) + (ly * ly) + (lz * lz))))
|
| 463 |
-
lx /= light_norm
|
| 464 |
-
ly /= light_norm
|
| 465 |
-
lz /= light_norm
|
| 466 |
-
|
| 467 |
-
diffuse = np.clip((nx * lx) + (ny * ly) + (nz * lz), 0.0, 1.0)
|
| 468 |
-
strength = float(np.clip(light_intensity, 0.0, 1.0))
|
| 469 |
-
if material == "acm":
|
| 470 |
-
return np.clip(0.97 + (diffuse * (0.03 + (0.12 * strength))), 0.95, 1.12)
|
| 471 |
-
return np.clip(0.86 + (diffuse * (0.14 + (0.60 * strength))), 0.72, 1.35)
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
def build_mask_edge_occlusion(binary_mask: np.ndarray, light_intensity: float) -> np.ndarray:
|
| 475 |
-
mask_u8 = (binary_mask > 0).astype(np.uint8)
|
| 476 |
-
if mask_u8.max() == 0:
|
| 477 |
-
return np.ones(mask_u8.shape, dtype=np.float32)
|
| 478 |
-
|
| 479 |
-
distance = cv2.distanceTransform(mask_u8, cv2.DIST_L2, 5).astype(np.float32)
|
| 480 |
-
inner_values = distance[mask_u8 > 0]
|
| 481 |
-
if inner_values.size == 0:
|
| 482 |
-
return np.ones(mask_u8.shape, dtype=np.float32)
|
| 483 |
-
|
| 484 |
-
max_distance = max(1.0, float(np.percentile(inner_values, 95)))
|
| 485 |
-
normalized = np.clip(distance / (max_distance * 0.16), 0.0, 1.0)
|
| 486 |
-
edge_strength = 1.0 - normalized
|
| 487 |
-
occlusion = 1.0 - (edge_strength * (0.04 + (0.08 * float(np.clip(light_intensity, 0.0, 1.0)))))
|
| 488 |
-
occlusion[mask_u8 == 0] = 1.0
|
| 489 |
-
return np.clip(occlusion, 0.88, 1.0)
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
def apply_surface_lighting(
|
| 493 |
-
tex_rgb: np.ndarray,
|
| 494 |
-
orig_rgb: np.ndarray,
|
| 495 |
-
binary_mask: np.ndarray,
|
| 496 |
-
material: str,
|
| 497 |
-
lighting_mode: str,
|
| 498 |
-
light_angle_degrees: float,
|
| 499 |
-
light_intensity: float,
|
| 500 |
-
) -> np.ndarray:
|
| 501 |
-
scene_light = build_scene_luminance_map(orig_rgb)
|
| 502 |
-
directional_light = build_directional_light_map(tex_rgb, material, light_angle_degrees, light_intensity)
|
| 503 |
-
relief_map = build_texture_relief_map(tex_rgb, material)
|
| 504 |
-
edge_occlusion = build_mask_edge_occlusion(binary_mask, light_intensity)
|
| 505 |
-
|
| 506 |
-
if material == "acm":
|
| 507 |
-
if lighting_mode == "directional":
|
| 508 |
-
light_map = directional_light
|
| 509 |
-
elif lighting_mode == "flat":
|
| 510 |
-
light_map = np.ones(scene_light.shape, dtype=np.float32)
|
| 511 |
-
else:
|
| 512 |
-
# 45 % scene luminance so ACM panels inherit shadows/gradients from photo
|
| 513 |
-
light_map = (scene_light * 0.45) + (directional_light * 0.55)
|
| 514 |
-
elif lighting_mode == "directional":
|
| 515 |
-
light_map = directional_light
|
| 516 |
-
elif lighting_mode == "flat":
|
| 517 |
-
light_map = np.ones(scene_light.shape, dtype=np.float32)
|
| 518 |
-
else:
|
| 519 |
-
light_map = (scene_light * 0.78) + (directional_light * 0.22)
|
| 520 |
-
|
| 521 |
-
detail_scale = 0.02 + (0.05 * float(np.clip(light_intensity, 0.0, 1.0))) if material == "acm" else 0.08 + (0.18 * float(np.clip(light_intensity, 0.0, 1.0)))
|
| 522 |
-
detail_boost = 1.0 + (relief_map * detail_scale)
|
| 523 |
-
enhanced = tex_rgb.astype(np.float32)
|
| 524 |
-
enhanced *= light_map[:, :, None]
|
| 525 |
-
enhanced *= detail_boost[:, :, None]
|
| 526 |
-
enhanced *= edge_occlusion[:, :, None]
|
| 527 |
-
return np.clip(enhanced, 0, 255).astype(np.uint8)
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
def blend_texture_preserve_shading(
|
| 531 |
-
orig_rgb: np.ndarray,
|
| 532 |
-
tex_rgb: np.ndarray,
|
| 533 |
-
alpha_mask: np.ndarray,
|
| 534 |
-
blend_alpha: float,
|
| 535 |
-
material: str = "generic",
|
| 536 |
-
) -> np.ndarray:
|
| 537 |
-
orig_u8 = orig_rgb.astype(np.uint8)
|
| 538 |
-
tex_u8 = tex_rgb.astype(np.uint8)
|
| 539 |
-
|
| 540 |
-
orig_lab = cv2.cvtColor(orig_u8, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 541 |
-
tex_lab = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 542 |
-
|
| 543 |
-
mixed_lab = tex_lab.copy()
|
| 544 |
-
if material == "acm":
|
| 545 |
-
# 30 % original luminance → panels inherit scene shadows while keeping panel colour
|
| 546 |
-
mixed_lab[:, :, 0] = (0.30 * orig_lab[:, :, 0]) + (0.70 * tex_lab[:, :, 0])
|
| 547 |
-
mixed_lab[:, :, 1] = (0.97 * tex_lab[:, :, 1]) + (0.03 * orig_lab[:, :, 1])
|
| 548 |
-
mixed_lab[:, :, 2] = (0.97 * tex_lab[:, :, 2]) + (0.03 * orig_lab[:, :, 2])
|
| 549 |
-
elif material == "wood":
|
| 550 |
-
mixed_lab[:, :, 0] = (0.78 * orig_lab[:, :, 0]) + (0.22 * tex_lab[:, :, 0])
|
| 551 |
-
mixed_lab[:, :, 1] = (0.9 * tex_lab[:, :, 1]) + (0.1 * orig_lab[:, :, 1])
|
| 552 |
-
mixed_lab[:, :, 2] = (0.9 * tex_lab[:, :, 2]) + (0.1 * orig_lab[:, :, 2])
|
| 553 |
-
elif material == "stone":
|
| 554 |
-
orig_l_base = cv2.GaussianBlur(orig_lab[:, :, 0], (0, 0), sigmaX=11.0, sigmaY=11.0)
|
| 555 |
-
mixed_lab[:, :, 0] = (0.18 * orig_l_base) + (0.82 * tex_lab[:, :, 0])
|
| 556 |
-
mixed_lab[:, :, 1] = (0.95 * tex_lab[:, :, 1]) + (0.05 * orig_lab[:, :, 1])
|
| 557 |
-
mixed_lab[:, :, 2] = (0.95 * tex_lab[:, :, 2]) + (0.05 * orig_lab[:, :, 2])
|
| 558 |
-
else:
|
| 559 |
-
mixed_lab[:, :, 0] = orig_lab[:, :, 0]
|
| 560 |
-
mixed_lab[:, :, 1] = (0.8 * tex_lab[:, :, 1]) + (0.2 * orig_lab[:, :, 1])
|
| 561 |
-
mixed_lab[:, :, 2] = (0.8 * tex_lab[:, :, 2]) + (0.2 * orig_lab[:, :, 2])
|
| 562 |
-
|
| 563 |
-
shaded_tex = cv2.cvtColor(np.clip(mixed_lab, 0, 255).astype(np.uint8), cv2.COLOR_LAB2RGB).astype(np.float32)
|
| 564 |
-
if material == "wood":
|
| 565 |
-
tex_gray = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2GRAY).astype(np.float32)
|
| 566 |
-
tex_base = cv2.GaussianBlur(tex_gray, (0, 0), sigmaX=9.0, sigmaY=9.0)
|
| 567 |
-
tex_detail = np.clip((tex_gray - tex_base) / 255.0, -0.35, 0.35)
|
| 568 |
-
shaded_tex *= (1.0 + (tex_detail[:, :, None] * 0.28))
|
| 569 |
-
|
| 570 |
-
alpha = np.clip(alpha_mask[:, :, None] * float(blend_alpha), 0.0, 1.0)
|
| 571 |
-
composite = (orig_rgb * (1.0 - alpha)) + (shaded_tex * alpha)
|
| 572 |
-
return np.clip(composite, 0, 255).astype(np.uint8)
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
def blend_texture_direct(
|
| 576 |
-
orig_rgb: np.ndarray,
|
| 577 |
-
tex_rgb: np.ndarray,
|
| 578 |
-
alpha_mask: np.ndarray,
|
| 579 |
-
blend_alpha: float,
|
| 580 |
-
) -> np.ndarray:
|
| 581 |
-
alpha = np.clip(alpha_mask[:, :, None] * float(blend_alpha), 0.0, 1.0)
|
| 582 |
-
composite = (orig_rgb * (1.0 - alpha)) + (tex_rgb * alpha)
|
| 583 |
-
return np.clip(composite, 0, 255).astype(np.uint8)
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
def apply_local_texture_sync(payload: ApplyTextureRequest) -> dict[str, Any]:
|
| 587 |
-
step = "APPLY_TEXTURE"
|
| 588 |
-
started = log_timing_start(step)
|
| 589 |
-
try:
|
| 590 |
-
safe_name = Path(payload.filename).name
|
| 591 |
-
if not safe_name:
|
| 592 |
-
raise HTTPException(status_code=400, detail="Invalid filename")
|
| 593 |
-
|
| 594 |
-
label_safe_name = Path(payload.original_filename).name if payload.original_filename else safe_name
|
| 595 |
-
|
| 596 |
-
image_path = UPLOAD_DIR / safe_name
|
| 597 |
-
if not image_path.exists() or not image_path.is_file():
|
| 598 |
-
image_path = OUTPUT_DIR / safe_name
|
| 599 |
-
|
| 600 |
-
if (not image_path.exists() or not image_path.is_file()) and payload.original_filename:
|
| 601 |
-
orig_name = Path(payload.original_filename).name
|
| 602 |
-
image_path = UPLOAD_DIR / orig_name
|
| 603 |
-
if not image_path.exists() or not image_path.is_file():
|
| 604 |
-
image_path = OUTPUT_DIR / orig_name
|
| 605 |
-
|
| 606 |
-
if not image_path.exists() or not image_path.is_file():
|
| 607 |
-
raise HTTPException(
|
| 608 |
-
status_code=404,
|
| 609 |
-
detail=f"Image not found: {safe_name} (also tried original: {payload.original_filename or 'n/a'})",
|
| 610 |
-
)
|
| 611 |
-
|
| 612 |
-
masks_dir = UPLOAD_DIR / "masks"
|
| 613 |
-
masks_dir.mkdir(exist_ok=True)
|
| 614 |
-
label_owner = Path(image_path).stem
|
| 615 |
-
label_path = masks_dir / f"{label_owner}_labels.png"
|
| 616 |
-
if not label_path.exists() and payload.original_filename:
|
| 617 |
-
alt_owner = Path(payload.original_filename).name
|
| 618 |
-
alt_label = masks_dir / f"{alt_owner}_labels.png"
|
| 619 |
-
if alt_label.exists():
|
| 620 |
-
label_path = alt_label
|
| 621 |
-
|
| 622 |
-
if not label_path.exists():
|
| 623 |
-
raise HTTPException(
|
| 624 |
-
status_code=404,
|
| 625 |
-
detail=f"Label map not found for {label_owner}. Upload/segment the image first.",
|
| 626 |
-
)
|
| 627 |
-
|
| 628 |
-
if not payload.mask_indices:
|
| 629 |
-
raise HTTPException(status_code=400, detail="No mask indices provided")
|
| 630 |
-
|
| 631 |
-
texture_path = resolve_texture_path(payload.texture_name)
|
| 632 |
-
orig_pil = Image.open(str(image_path)).convert("RGB")
|
| 633 |
-
width, height = orig_pil.size
|
| 634 |
-
|
| 635 |
-
label_map = cv2.imread(str(label_path), cv2.IMREAD_GRAYSCALE)
|
| 636 |
-
if label_map is None:
|
| 637 |
-
raise HTTPException(status_code=500, detail="Could not read label map")
|
| 638 |
-
|
| 639 |
-
binary_mask = np.zeros((label_map.shape[0], label_map.shape[1]), dtype=np.uint8)
|
| 640 |
-
for idx in payload.mask_indices:
|
| 641 |
-
binary_mask |= (label_map == idx).astype(np.uint8)
|
| 642 |
-
|
| 643 |
-
if binary_mask.max() == 0:
|
| 644 |
-
raise HTTPException(status_code=400, detail="None of the selected segments were found in the label map.")
|
| 645 |
-
|
| 646 |
-
direction_mode = str(payload.direction_mode or "auto").strip().lower()
|
| 647 |
-
if direction_mode not in {"auto", "manual", "none"}:
|
| 648 |
-
raise HTTPException(status_code=400, detail="Invalid direction_mode. Use auto, manual, or none.")
|
| 649 |
-
|
| 650 |
-
replace_mode = str(getattr(payload, "replace_mode", "realistic") or "realistic").strip().lower()
|
| 651 |
-
lighting_mode = str(getattr(payload, "lighting_mode", "scene") or "scene").strip().lower()
|
| 652 |
-
|
| 653 |
-
material = classify_texture_material(payload.texture_name)
|
| 654 |
-
surface_type, inferred_angle, blend_alpha, target_w = infer_surface_type_and_direction(
|
| 655 |
-
binary_mask, width, height, payload.texture_name,
|
| 656 |
-
)
|
| 657 |
-
replace_strength, light_angle_degrees, light_intensity = choose_auto_texture_settings(material, surface_type)
|
| 658 |
-
effective_alpha = float(np.clip(blend_alpha * (0.55 + (0.75 * replace_strength)), 0.0, 0.98))
|
| 659 |
-
if material == "acm":
|
| 660 |
-
effective_alpha = float(max(effective_alpha, 0.92))
|
| 661 |
-
|
| 662 |
-
applied_angle = 0.0
|
| 663 |
-
if direction_mode == "auto":
|
| 664 |
-
applied_angle = inferred_angle
|
| 665 |
-
elif direction_mode == "manual":
|
| 666 |
-
applied_angle = float(payload.angle_degrees)
|
| 667 |
-
|
| 668 |
-
tex_pil = load_texture_pil_rgb(texture_path)
|
| 669 |
-
|
| 670 |
-
# Escalar al tamaño de tile deseado ANTES de rotar
|
| 671 |
-
tex_w, tex_h = tex_pil.size
|
| 672 |
-
scale = target_w / max(1, tex_w)
|
| 673 |
-
if abs(scale - 1.0) > 0.05:
|
| 674 |
-
tex_pil = tex_pil.resize(
|
| 675 |
-
(max(1, int(tex_w * scale)), max(1, int(tex_h * scale))),
|
| 676 |
-
Image.Resampling.LANCZOS,
|
| 677 |
-
)
|
| 678 |
-
tex_w, tex_h = tex_pil.size
|
| 679 |
-
|
| 680 |
-
tiled: Image.Image | None = None
|
| 681 |
-
|
| 682 |
-
# Floor surfaces: perspective tiling solo cuando la perspectiva es fuerte.
|
| 683 |
-
# Para pisos de dormitorio/habitación (perspectiva suave) el quad aproximado
|
| 684 |
-
# no cubre bien el mask irregular → produce negro. Se usa tiling regular en esos casos.
|
| 685 |
-
if surface_type == "floor" and direction_mode in {"auto", "none"}:
|
| 686 |
-
ys_m, xs_m = np.where(binary_mask > 0)
|
| 687 |
-
if ys_m.size > 0:
|
| 688 |
-
min_y_m, max_y_m = int(ys_m.min()), int(ys_m.max())
|
| 689 |
-
bbox_h_m = max(1, max_y_m - min_y_m + 1)
|
| 690 |
-
trap_score = _compute_trapezoid_score_from_mask(
|
| 691 |
-
binary_mask, ys_m, xs_m, min_y_m, max_y_m, bbox_h_m
|
| 692 |
-
)
|
| 693 |
-
if trap_score > 0.35:
|
| 694 |
-
quad = _extract_mask_quad(binary_mask)
|
| 695 |
-
if quad is not None:
|
| 696 |
-
tiled = _tile_texture_perspective(tex_pil, quad, width, height)
|
| 697 |
-
if tiled is not None:
|
| 698 |
-
logger.info(f"[APPLY_TEXTURE] perspective floor tiling applied (trap={trap_score:.2f})")
|
| 699 |
-
else:
|
| 700 |
-
logger.info(f"[APPLY_TEXTURE] flat floor tiling (trap={trap_score:.2f} < 0.35, skip perspective)")
|
| 701 |
-
|
| 702 |
-
# Wall / ceiling surfaces: perspective tiling cuando el quad es significativamente
|
| 703 |
-
# no-rectangular (pared fotografiada en ángulo). Un ratio > 1.20 entre lado mayor
|
| 704 |
-
# y lado menor indica distorsión perspectiva visible.
|
| 705 |
-
if surface_type in {"wall", "ceiling"} and tiled is None and direction_mode in {"auto", "none"}:
|
| 706 |
-
quad = _extract_mask_quad(binary_mask)
|
| 707 |
-
if quad is not None:
|
| 708 |
-
tl, tr, br, bl = quad
|
| 709 |
-
top_w = float(np.linalg.norm(tr.astype(float) - tl.astype(float)))
|
| 710 |
-
bot_w = float(np.linalg.norm(br.astype(float) - bl.astype(float)))
|
| 711 |
-
left_h = float(np.linalg.norm(bl.astype(float) - tl.astype(float)))
|
| 712 |
-
right_h = float(np.linalg.norm(br.astype(float) - tr.astype(float)))
|
| 713 |
-
w_ratio = max(top_w, bot_w) / max(1.0, min(top_w, bot_w))
|
| 714 |
-
h_ratio = max(left_h, right_h) / max(1.0, min(left_h, right_h))
|
| 715 |
-
if w_ratio > 1.20 or h_ratio > 1.20:
|
| 716 |
-
tiled = _tile_texture_perspective(tex_pil, quad, width, height)
|
| 717 |
-
if tiled is not None:
|
| 718 |
-
logger.info(
|
| 719 |
-
f"[APPLY_TEXTURE] perspective wall tiling applied "
|
| 720 |
-
f"(w_ratio={w_ratio:.2f}, h_ratio={h_ratio:.2f})"
|
| 721 |
-
)
|
| 722 |
-
|
| 723 |
-
if tiled is None:
|
| 724 |
-
if abs(applied_angle) > 0.01:
|
| 725 |
-
# Tile on a large canvas first, then rotate the full canvas to avoid black corners
|
| 726 |
-
diag = int(np.ceil(np.sqrt(width ** 2 + height ** 2))) + max(tex_w, tex_h)
|
| 727 |
-
large_w = width + diag
|
| 728 |
-
large_h = height + diag
|
| 729 |
-
large = Image.new("RGB", (large_w, large_h))
|
| 730 |
-
for y in range(0, large_h, tex_h):
|
| 731 |
-
for x in range(0, large_w, tex_w):
|
| 732 |
-
large.paste(tex_pil, (x, y))
|
| 733 |
-
large = large.rotate(-applied_angle, resample=Image.Resampling.BICUBIC, expand=False)
|
| 734 |
-
cx = (large_w - width) // 2
|
| 735 |
-
cy = (large_h - height) // 2
|
| 736 |
-
tiled = large.crop((cx, cy, cx + width, cy + height))
|
| 737 |
-
else:
|
| 738 |
-
tiled = Image.new("RGB", (width, height))
|
| 739 |
-
for y in range(0, height, tex_h):
|
| 740 |
-
for x in range(0, width, tex_w):
|
| 741 |
-
tiled.paste(tex_pil, (x, y))
|
| 742 |
-
|
| 743 |
-
orig_u8 = np.array(orig_pil, dtype=np.uint8)
|
| 744 |
-
|
| 745 |
-
try:
|
| 746 |
-
if bool(getattr(payload, "clear_mask_before_apply", False)):
|
| 747 |
-
orig_candidate = None
|
| 748 |
-
if payload.original_filename:
|
| 749 |
-
cand = UPLOAD_DIR / Path(payload.original_filename).name
|
| 750 |
-
if cand.exists() and cand.is_file():
|
| 751 |
-
orig_candidate = cand
|
| 752 |
-
else:
|
| 753 |
-
cand2 = OUTPUT_DIR / Path(payload.original_filename).name
|
| 754 |
-
if cand2.exists() and cand2.is_file():
|
| 755 |
-
orig_candidate = cand2
|
| 756 |
-
if orig_candidate is None:
|
| 757 |
-
stem = Path(image_path).stem
|
| 758 |
-
if "_edit_" in stem:
|
| 759 |
-
base_name = stem.split("_edit_")[0] + ".jpg"
|
| 760 |
-
cand = UPLOAD_DIR / base_name
|
| 761 |
-
if cand.exists() and cand.is_file():
|
| 762 |
-
orig_candidate = cand
|
| 763 |
-
else:
|
| 764 |
-
cand2 = OUTPUT_DIR / base_name
|
| 765 |
-
if cand2.exists() and cand2.is_file():
|
| 766 |
-
orig_candidate = cand2
|
| 767 |
-
|
| 768 |
-
if orig_candidate is not None:
|
| 769 |
-
try:
|
| 770 |
-
base_pil = Image.open(str(orig_candidate)).convert("RGB")
|
| 771 |
-
if base_pil.size != (width, height):
|
| 772 |
-
base_pil = base_pil.resize((width, height), Image.Resampling.LANCZOS)
|
| 773 |
-
base_arr = np.array(base_pil, dtype=np.uint8)
|
| 774 |
-
mask_bool = (binary_mask > 0)
|
| 775 |
-
orig_u8[mask_bool] = base_arr[mask_bool]
|
| 776 |
-
logger.info(f"[APPLY_TEXTURE] cleared mask from original source: {orig_candidate}")
|
| 777 |
-
except Exception:
|
| 778 |
-
logger.exception("Failed to restore original pixels for clear_mask_before_apply")
|
| 779 |
-
except Exception:
|
| 780 |
-
logger.exception("Error handling clear_mask_before_apply")
|
| 781 |
-
|
| 782 |
-
tiled_arr = np.array(tiled, dtype=np.uint8)
|
| 783 |
-
|
| 784 |
-
# Parchar píxeles muy oscuros (suma R+G+B < 20) dentro de la máscara.
|
| 785 |
-
# Cubren tanto negro exacto (0,0,0) como píxeles casi negros que el warp
|
| 786 |
-
# de perspectiva puede generar en bordes del quad o zonas sin cobertura.
|
| 787 |
-
mask_bool = binary_mask > 0
|
| 788 |
-
dark_in_mask = mask_bool & (tiled_arr.sum(axis=2) < 20)
|
| 789 |
-
if dark_in_mask.any():
|
| 790 |
-
th_f, tw_f = tex_pil.size[1], tex_pil.size[0]
|
| 791 |
-
tex_np = np.array(tex_pil, dtype=np.uint8)
|
| 792 |
-
ys_b, xs_b = np.where(dark_in_mask)
|
| 793 |
-
tiled_arr[ys_b, xs_b] = tex_np[ys_b % th_f, xs_b % tw_f]
|
| 794 |
-
|
| 795 |
-
lit_tex = apply_surface_lighting(
|
| 796 |
-
tiled_arr,
|
| 797 |
-
orig_u8,
|
| 798 |
-
binary_mask,
|
| 799 |
-
material,
|
| 800 |
-
lighting_mode,
|
| 801 |
-
light_angle_degrees,
|
| 802 |
-
light_intensity,
|
| 803 |
-
)
|
| 804 |
-
|
| 805 |
-
orig_arr = orig_u8.astype(np.float32)
|
| 806 |
-
tex_arr = lit_tex.astype(np.float32)
|
| 807 |
-
|
| 808 |
-
if replace_mode in {"hard", "absolute", "force", "replace"}:
|
| 809 |
-
mask_bool = (binary_mask > 0).astype(bool)
|
| 810 |
-
composite_arr = orig_arr.copy()
|
| 811 |
-
composite_arr[mask_bool] = tex_arr[mask_bool]
|
| 812 |
-
composite = np.clip(composite_arr, 0, 255).astype(np.uint8)
|
| 813 |
-
else:
|
| 814 |
-
feather_mask = build_feather_mask(binary_mask)
|
| 815 |
-
# All materials use shading-preservation so scene luminance (shadows/highlights)
|
| 816 |
-
# from the original photo is transferred onto the texture.
|
| 817 |
-
composite = blend_texture_preserve_shading(orig_arr, tex_arr, feather_mask, effective_alpha, material)
|
| 818 |
-
|
| 819 |
-
input_stem = Path(image_path).stem
|
| 820 |
-
edit_suffix = uuid.uuid4().hex[:8]
|
| 821 |
-
out_filename = f"{input_stem}_edit_{edit_suffix}.jpg"
|
| 822 |
-
out_path = UPLOAD_DIR / out_filename
|
| 823 |
-
Image.fromarray(composite).save(str(out_path), format="JPEG", quality=UPLOAD_JPEG_QUALITY, optimize=True)
|
| 824 |
-
|
| 825 |
-
try:
|
| 826 |
-
out_label_path = masks_dir / f"{Path(out_filename).stem}_labels.png"
|
| 827 |
-
if label_path.exists():
|
| 828 |
-
shutil.copyfile(str(label_path), str(out_label_path))
|
| 829 |
-
except Exception:
|
| 830 |
-
logger.exception("Failed to copy label map for output image")
|
| 831 |
-
|
| 832 |
-
return {
|
| 833 |
-
"message": "Texture applied successfully",
|
| 834 |
-
"original": safe_name,
|
| 835 |
-
"mask_indices": payload.mask_indices,
|
| 836 |
-
"texture_name": payload.texture_name,
|
| 837 |
-
"material": material,
|
| 838 |
-
"direction_mode": direction_mode,
|
| 839 |
-
"surface_type": surface_type,
|
| 840 |
-
"replace_mode": replace_mode,
|
| 841 |
-
"replace_strength": round(replace_strength, 3),
|
| 842 |
-
"lighting_mode": lighting_mode,
|
| 843 |
-
"light_angle_degrees": round(light_angle_degrees, 2),
|
| 844 |
-
"light_intensity": round(light_intensity, 3),
|
| 845 |
-
"blend_alpha": round(effective_alpha, 3),
|
| 846 |
-
"applied_angle_degrees": round(applied_angle, 2),
|
| 847 |
-
"output_filename": out_filename,
|
| 848 |
-
"output_url": f"/seg/image/{out_filename}",
|
| 849 |
-
}
|
| 850 |
-
finally:
|
| 851 |
-
log_timing_end(step, started)
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import shutil
|
| 3 |
+
import uuid
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
import cv2
|
| 8 |
+
import numpy as np
|
| 9 |
+
from fastapi import HTTPException
|
| 10 |
+
from PIL import Image
|
| 11 |
+
|
| 12 |
+
from core.config import (
|
| 13 |
+
OUTPUT_DIR,
|
| 14 |
+
TEXTURE_DIR,
|
| 15 |
+
UPLOAD_DIR,
|
| 16 |
+
UPLOAD_JPEG_QUALITY,
|
| 17 |
+
log_timing_end,
|
| 18 |
+
log_timing_start,
|
| 19 |
+
logger,
|
| 20 |
+
)
|
| 21 |
+
from models.schemas import ApplyTextureRequest
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def generate_texture_variations(texture_name: str) -> list[dict[str, str]]:
|
| 25 |
+
"""
|
| 26 |
+
Genera variaciones de color/brillo/saturación de una textura de referencia usando HSV.
|
| 27 |
+
Los archivos se cachean en TEXTURE_DIR/generated/ — si ya existen no se regeneran.
|
| 28 |
+
Devuelve lista de {ref, label, preview_url}.
|
| 29 |
+
"""
|
| 30 |
+
texture_path = resolve_texture_path(texture_name)
|
| 31 |
+
generated_dir = TEXTURE_DIR / "generated"
|
| 32 |
+
generated_dir.mkdir(parents=True, exist_ok=True)
|
| 33 |
+
|
| 34 |
+
tex_pil = load_texture_pil_rgb(texture_path)
|
| 35 |
+
tex_bgr = cv2.cvtColor(np.array(tex_pil, dtype=np.uint8), cv2.COLOR_RGB2BGR)
|
| 36 |
+
tex_hsv = cv2.cvtColor(tex_bgr, cv2.COLOR_BGR2HSV).astype(np.int32)
|
| 37 |
+
|
| 38 |
+
base_stem = Path(texture_name).stem
|
| 39 |
+
results: list[dict[str, str]] = []
|
| 40 |
+
|
| 41 |
+
def _save(hsv: np.ndarray, suffix: str, label: str) -> None:
|
| 42 |
+
fname = f"{base_stem}__{suffix}.jpg"
|
| 43 |
+
out_path = generated_dir / fname
|
| 44 |
+
if not out_path.exists():
|
| 45 |
+
bgr = cv2.cvtColor(np.clip(hsv, 0, 255).astype(np.uint8), cv2.COLOR_HSV2BGR)
|
| 46 |
+
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
| 47 |
+
Image.fromarray(rgb).save(str(out_path), format="JPEG", quality=92, optimize=True)
|
| 48 |
+
ref = f"generated/{fname}"
|
| 49 |
+
results.append({"ref": ref, "label": label, "preview_url": f"/seg/texture-preview/{ref}"})
|
| 50 |
+
|
| 51 |
+
# Rotaciones de tono: 12 pasos de 30° recorriendo el círculo cromático
|
| 52 |
+
# En OpenCV HSV, H ∈ [0,179] → shift en grados / 2
|
| 53 |
+
for deg, label in [
|
| 54 |
+
(30, "Naranja"),
|
| 55 |
+
(60, "Amarillo"),
|
| 56 |
+
(90, "Verde lima"),
|
| 57 |
+
(120, "Verde"),
|
| 58 |
+
(150, "Verde agua"),
|
| 59 |
+
(165, "Cyan"),
|
| 60 |
+
(180, "Azul cielo"),
|
| 61 |
+
(210, "Azul"),
|
| 62 |
+
(240, "Índigo"),
|
| 63 |
+
(270, "Violeta"),
|
| 64 |
+
(300, "Magenta"),
|
| 65 |
+
(330, "Rosa"),
|
| 66 |
+
]:
|
| 67 |
+
v = tex_hsv.copy()
|
| 68 |
+
v[:, :, 0] = (v[:, :, 0] + deg // 2) % 180
|
| 69 |
+
_save(v, f"hue{deg}", label)
|
| 70 |
+
|
| 71 |
+
# Variaciones de brillo
|
| 72 |
+
for factor, label, suffix in [
|
| 73 |
+
(0.45, "Oscuro", "dark"),
|
| 74 |
+
(1.55, "Claro", "light"),
|
| 75 |
+
]:
|
| 76 |
+
v = tex_hsv.copy()
|
| 77 |
+
v[:, :, 2] = np.clip(v[:, :, 2] * factor, 0, 255)
|
| 78 |
+
_save(v, suffix, label)
|
| 79 |
+
|
| 80 |
+
# Variaciones de saturación
|
| 81 |
+
for factor, label, suffix in [
|
| 82 |
+
(0.0, "Gris", "gray"),
|
| 83 |
+
(0.45, "Apagado", "muted"),
|
| 84 |
+
(1.75, "Vívido", "vivid"),
|
| 85 |
+
]:
|
| 86 |
+
v = tex_hsv.copy()
|
| 87 |
+
v[:, :, 1] = np.clip(v[:, :, 1] * factor, 0, 255)
|
| 88 |
+
_save(v, suffix, label)
|
| 89 |
+
|
| 90 |
+
return results
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def list_available_textures() -> list[str]:
|
| 94 |
+
allowed = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff", ".exr"}
|
| 95 |
+
return [
|
| 96 |
+
str(path.relative_to(TEXTURE_DIR)).replace("\\", "/")
|
| 97 |
+
for path in sorted(TEXTURE_DIR.rglob("*"))
|
| 98 |
+
if path.is_file() and path.suffix.lower() in allowed
|
| 99 |
+
]
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def resolve_texture_path(texture_name: str) -> Path:
|
| 103 |
+
if not texture_name:
|
| 104 |
+
raise HTTPException(status_code=400, detail="Invalid texture_name")
|
| 105 |
+
|
| 106 |
+
normalized = texture_name.replace("\\", "/").strip("/")
|
| 107 |
+
candidate = (TEXTURE_DIR / normalized).resolve()
|
| 108 |
+
base = TEXTURE_DIR.resolve()
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
candidate.relative_to(base)
|
| 112 |
+
except ValueError as exc:
|
| 113 |
+
raise HTTPException(status_code=400, detail="Invalid texture_name") from exc
|
| 114 |
+
|
| 115 |
+
if not candidate.exists() or not candidate.is_file():
|
| 116 |
+
raise HTTPException(status_code=404, detail=f"Texture not found: {normalized}")
|
| 117 |
+
|
| 118 |
+
return candidate
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def build_texture_preview_jpeg(texture_path: Path, max_size: int = 320) -> bytes:
|
| 122 |
+
pil_img = load_texture_pil_rgb(texture_path)
|
| 123 |
+
pil_img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
|
| 124 |
+
out = io.BytesIO()
|
| 125 |
+
pil_img.save(out, format="JPEG", quality=88, optimize=True)
|
| 126 |
+
return out.getvalue()
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def load_texture_pil_rgb(texture_path: Path) -> Image.Image:
|
| 130 |
+
suffix = texture_path.suffix.lower()
|
| 131 |
+
|
| 132 |
+
if suffix != ".exr":
|
| 133 |
+
try:
|
| 134 |
+
return Image.open(str(texture_path)).convert("RGB")
|
| 135 |
+
except Exception as exc:
|
| 136 |
+
raise HTTPException(status_code=500, detail=f"Could not read texture file: {exc}") from exc
|
| 137 |
+
|
| 138 |
+
exr = cv2.imread(str(texture_path), cv2.IMREAD_UNCHANGED)
|
| 139 |
+
if exr is None:
|
| 140 |
+
raise HTTPException(status_code=500, detail="Could not decode EXR texture")
|
| 141 |
+
|
| 142 |
+
if exr.ndim == 2:
|
| 143 |
+
exr = np.stack([exr, exr, exr], axis=-1)
|
| 144 |
+
if exr.ndim != 3:
|
| 145 |
+
raise HTTPException(status_code=500, detail="EXR texture has unsupported shape")
|
| 146 |
+
if exr.shape[2] > 3:
|
| 147 |
+
exr = exr[:, :, :3]
|
| 148 |
+
|
| 149 |
+
exr = np.nan_to_num(exr, nan=0.0, posinf=0.0, neginf=0.0)
|
| 150 |
+
exr = np.maximum(exr, 0)
|
| 151 |
+
|
| 152 |
+
if np.issubdtype(exr.dtype, np.floating):
|
| 153 |
+
scale = float(np.percentile(exr, 99.0))
|
| 154 |
+
if scale <= 1e-8:
|
| 155 |
+
scale = float(np.max(exr))
|
| 156 |
+
if scale <= 1e-8:
|
| 157 |
+
scale = 1.0
|
| 158 |
+
img = np.clip(exr / scale, 0.0, 1.0)
|
| 159 |
+
img = np.power(img, 1.0 / 2.2)
|
| 160 |
+
img_u8 = (img * 255.0).astype(np.uint8)
|
| 161 |
+
elif exr.dtype == np.uint16:
|
| 162 |
+
img_u8 = (exr / 257.0).astype(np.uint8)
|
| 163 |
+
else:
|
| 164 |
+
img_u8 = np.clip(exr, 0, 255).astype(np.uint8)
|
| 165 |
+
|
| 166 |
+
img_rgb = cv2.cvtColor(img_u8, cv2.COLOR_BGR2RGB)
|
| 167 |
+
return Image.fromarray(img_rgb).convert("RGB")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def estimate_mask_orientation_degrees(binary_mask: np.ndarray) -> float:
|
| 171 |
+
mask_u8 = (binary_mask > 0).astype(np.uint8)
|
| 172 |
+
contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 173 |
+
if not contours:
|
| 174 |
+
return 0.0
|
| 175 |
+
|
| 176 |
+
largest = max(contours, key=cv2.contourArea)
|
| 177 |
+
if cv2.contourArea(largest) < 25.0:
|
| 178 |
+
return 0.0
|
| 179 |
+
|
| 180 |
+
rect = cv2.minAreaRect(largest)
|
| 181 |
+
(_, _), (width, height), angle = rect
|
| 182 |
+
|
| 183 |
+
dominant_angle = float(angle)
|
| 184 |
+
if width < height:
|
| 185 |
+
dominant_angle += 90.0
|
| 186 |
+
|
| 187 |
+
dominant_angle %= 180.0
|
| 188 |
+
return dominant_angle
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _compute_trapezoid_score_from_mask(
|
| 192 |
+
binary_mask: np.ndarray,
|
| 193 |
+
ys: np.ndarray,
|
| 194 |
+
xs: np.ndarray,
|
| 195 |
+
min_y: int,
|
| 196 |
+
max_y: int,
|
| 197 |
+
bbox_h: int,
|
| 198 |
+
) -> float:
|
| 199 |
+
"""Return 0..1 indicating how floor-like (wider at bottom) the mask shape is."""
|
| 200 |
+
quarter = max(1, bbox_h // 4)
|
| 201 |
+
top_xs = xs[ys <= (min_y + quarter)]
|
| 202 |
+
bot_xs = xs[ys >= (max_y - quarter)]
|
| 203 |
+
if len(top_xs) < 3 or len(bot_xs) < 3:
|
| 204 |
+
return 0.0
|
| 205 |
+
top_w = float(top_xs.max() - top_xs.min())
|
| 206 |
+
bot_w = float(bot_xs.max() - bot_xs.min())
|
| 207 |
+
if top_w < 5.0:
|
| 208 |
+
return 1.0 if bot_w > 20.0 else 0.0
|
| 209 |
+
ratio = bot_w / top_w
|
| 210 |
+
return float(np.clip((ratio - 1.0) / 1.8, 0.0, 1.0))
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _sort_quad_corners(pts: np.ndarray) -> np.ndarray:
|
| 214 |
+
"""Sort 4 points into [TL, TR, BR, BL] order."""
|
| 215 |
+
result = np.zeros((4, 2), dtype=np.float32)
|
| 216 |
+
s = pts[:, 0] + pts[:, 1]
|
| 217 |
+
d = pts[:, 0] - pts[:, 1]
|
| 218 |
+
result[0] = pts[np.argmin(s)]
|
| 219 |
+
result[1] = pts[np.argmax(d)]
|
| 220 |
+
result[2] = pts[np.argmax(s)]
|
| 221 |
+
result[3] = pts[np.argmin(d)]
|
| 222 |
+
return result
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _extract_mask_quad(binary_mask: np.ndarray) -> np.ndarray | None:
|
| 226 |
+
"""Approximate mask as 4-corner polygon sorted [TL, TR, BR, BL], or None."""
|
| 227 |
+
mask_u8 = (binary_mask > 0).astype(np.uint8)
|
| 228 |
+
contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 229 |
+
if not contours:
|
| 230 |
+
return None
|
| 231 |
+
largest = max(contours, key=cv2.contourArea)
|
| 232 |
+
if cv2.contourArea(largest) < 400.0:
|
| 233 |
+
return None
|
| 234 |
+
hull = cv2.convexHull(largest)
|
| 235 |
+
peri = cv2.arcLength(hull, True)
|
| 236 |
+
for eps_frac in (0.03, 0.05, 0.08, 0.10, 0.13):
|
| 237 |
+
approx = cv2.approxPolyDP(hull, eps_frac * peri, True)
|
| 238 |
+
if len(approx) == 4:
|
| 239 |
+
return _sort_quad_corners(approx.reshape(4, 2).astype(np.float32))
|
| 240 |
+
return None
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def _tile_texture_perspective(
|
| 244 |
+
tex_pil: Image.Image,
|
| 245 |
+
quad: np.ndarray,
|
| 246 |
+
image_width: int,
|
| 247 |
+
image_height: int,
|
| 248 |
+
) -> Image.Image | None:
|
| 249 |
+
"""
|
| 250 |
+
Tile texture with perspective correction for a floor surface.
|
| 251 |
+
quad: [TL, TR, BR, BL] in image coordinates.
|
| 252 |
+
Returns full-image-size PIL image with warped tiled texture, or None on failure.
|
| 253 |
+
Pixels outside the perspective quad are filled with regular tiling to avoid black gaps.
|
| 254 |
+
"""
|
| 255 |
+
tl, tr, br, bl = quad
|
| 256 |
+
bot_w = float(np.linalg.norm(br.astype(float) - bl.astype(float)))
|
| 257 |
+
top_w = float(np.linalg.norm(tr.astype(float) - tl.astype(float)))
|
| 258 |
+
left_h = float(np.linalg.norm(bl.astype(float) - tl.astype(float)))
|
| 259 |
+
right_h = float(np.linalg.norm(br.astype(float) - tr.astype(float)))
|
| 260 |
+
rect_w = min(int(max(bot_w, top_w)) + 1, image_width * 2)
|
| 261 |
+
rect_h = min(int(max(left_h, right_h)) + 1, image_height * 2)
|
| 262 |
+
if rect_w < 8 or rect_h < 8:
|
| 263 |
+
return None
|
| 264 |
+
tex_arr = np.array(tex_pil.convert("RGB"), dtype=np.uint8)
|
| 265 |
+
th, tw = tex_arr.shape[:2]
|
| 266 |
+
if tw < 1 or th < 1:
|
| 267 |
+
return None
|
| 268 |
+
rect_tiled = np.zeros((rect_h, rect_w, 3), dtype=np.uint8)
|
| 269 |
+
for ry in range(0, rect_h, th):
|
| 270 |
+
for rx in range(0, rect_w, tw):
|
| 271 |
+
py = min(th, rect_h - ry)
|
| 272 |
+
px = min(tw, rect_w - rx)
|
| 273 |
+
rect_tiled[ry : ry + py, rx : rx + px] = tex_arr[:py, :px]
|
| 274 |
+
src_pts = np.array(
|
| 275 |
+
[[0.0, 0.0], [float(rect_w - 1), 0.0], [float(rect_w - 1), float(rect_h - 1)], [0.0, float(rect_h - 1)]],
|
| 276 |
+
dtype=np.float32,
|
| 277 |
+
)
|
| 278 |
+
dst_pts = quad.astype(np.float32)
|
| 279 |
+
try:
|
| 280 |
+
H = cv2.getPerspectiveTransform(src_pts, dst_pts)
|
| 281 |
+
warped = cv2.warpPerspective(rect_tiled, H, (image_width, image_height))
|
| 282 |
+
# Mapa de cobertura: píxeles realmente cubiertos por el warp
|
| 283 |
+
cov_src = np.ones((rect_h, rect_w), dtype=np.uint8) * 255
|
| 284 |
+
coverage = cv2.warpPerspective(cov_src, H, (image_width, image_height))
|
| 285 |
+
except cv2.error:
|
| 286 |
+
return None
|
| 287 |
+
|
| 288 |
+
# Rellenar píxeles sin cobertura (fuera del quad) con tiling regular
|
| 289 |
+
# para evitar espacios negros donde la máscara supera el quad aproximado
|
| 290 |
+
regular = np.zeros((image_height, image_width, 3), dtype=np.uint8)
|
| 291 |
+
for ry in range(0, image_height, th):
|
| 292 |
+
for rx in range(0, image_width, tw):
|
| 293 |
+
py = min(th, image_height - ry)
|
| 294 |
+
px = min(tw, image_width - rx)
|
| 295 |
+
regular[ry : ry + py, rx : rx + px] = tex_arr[:py, :px]
|
| 296 |
+
|
| 297 |
+
uncovered = coverage < 128
|
| 298 |
+
warped[uncovered] = regular[uncovered]
|
| 299 |
+
|
| 300 |
+
return Image.fromarray(warped)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def classify_texture_material(texture_name: str) -> str:
|
| 304 |
+
texture_key = texture_name.lower()
|
| 305 |
+
if "acm" in texture_key or "wpc" in texture_key:
|
| 306 |
+
return "acm"
|
| 307 |
+
if any(hint in texture_key for hint in ("deck", "wood", "plank", "laminate", "floor")):
|
| 308 |
+
return "wood"
|
| 309 |
+
if any(hint in texture_key for hint in ("marble", "granite", "tile", "brick", "cobblestone", "stone", "cartago", "riverbed")):
|
| 310 |
+
return "stone"
|
| 311 |
+
if any(hint in texture_key for hint in ("metal", "rust", "iron", "steel")):
|
| 312 |
+
return "metal"
|
| 313 |
+
return "generic"
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def infer_surface_type_and_direction(
|
| 317 |
+
binary_mask: np.ndarray,
|
| 318 |
+
image_width: int,
|
| 319 |
+
image_height: int,
|
| 320 |
+
texture_name: str,
|
| 321 |
+
) -> tuple[str, float, float, int]:
|
| 322 |
+
mask_u8 = (binary_mask > 0).astype(np.uint8)
|
| 323 |
+
ys, xs = np.where(mask_u8 > 0)
|
| 324 |
+
if ys.size == 0 or xs.size == 0:
|
| 325 |
+
return ("wall", 0.0, 0.78, max(180, image_width // 4))
|
| 326 |
+
|
| 327 |
+
min_x, max_x = int(xs.min()), int(xs.max())
|
| 328 |
+
min_y, max_y = int(ys.min()), int(ys.max())
|
| 329 |
+
bbox_w = max(1, max_x - min_x + 1)
|
| 330 |
+
bbox_h = max(1, max_y - min_y + 1)
|
| 331 |
+
aspect = bbox_w / max(1.0, float(bbox_h))
|
| 332 |
+
center_y = float(ys.mean()) / max(1.0, float(image_height))
|
| 333 |
+
dominant_angle = estimate_mask_orientation_degrees(binary_mask)
|
| 334 |
+
material = classify_texture_material(texture_name)
|
| 335 |
+
|
| 336 |
+
# Trapezoid score: floor in perspective is wider at the bottom than the top
|
| 337 |
+
trapezoid_score = _compute_trapezoid_score_from_mask(binary_mask, ys, xs, min_y, max_y, bbox_h)
|
| 338 |
+
|
| 339 |
+
is_ceiling = center_y < 0.26 and aspect > 1.35
|
| 340 |
+
# Floor: low center + trapezoidal shape, OR clearly low + wide
|
| 341 |
+
is_floor = (
|
| 342 |
+
(center_y > 0.55 and aspect >= 0.9 and trapezoid_score > 0.30)
|
| 343 |
+
or (center_y > 0.68 and aspect > 1.15)
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
if is_ceiling:
|
| 347 |
+
surface_type = "ceiling"
|
| 348 |
+
angle = 0.0
|
| 349 |
+
blend_alpha = 0.58
|
| 350 |
+
tile_width = max(128, image_width // 5)
|
| 351 |
+
elif is_floor:
|
| 352 |
+
if material == "wood":
|
| 353 |
+
surface_type = "deck"
|
| 354 |
+
angle = dominant_angle if 8.0 <= dominant_angle <= 172.0 else 0.0
|
| 355 |
+
blend_alpha = 0.82
|
| 356 |
+
tile_width = max(320, int(bbox_w * 0.95), image_width // 2)
|
| 357 |
+
else:
|
| 358 |
+
surface_type = "floor"
|
| 359 |
+
angle = 0.0
|
| 360 |
+
blend_alpha = 0.80
|
| 361 |
+
# ACM floor: ~3 large-format panels visible on the near edge
|
| 362 |
+
tile_width = max(200, int(bbox_w * 0.35)) if material == "acm" else max(144, image_width // 3)
|
| 363 |
+
else:
|
| 364 |
+
surface_type = "wall"
|
| 365 |
+
angle = 0.0
|
| 366 |
+
if material == "acm":
|
| 367 |
+
blend_alpha = 0.78
|
| 368 |
+
# ACM wall panels: ~3 panels across the surface width
|
| 369 |
+
tile_width = max(180, int(bbox_w * 0.33))
|
| 370 |
+
elif material == "wood":
|
| 371 |
+
blend_alpha = 0.70
|
| 372 |
+
tile_width = max(220, int(bbox_w * 0.55), image_width // 4)
|
| 373 |
+
elif material == "stone":
|
| 374 |
+
blend_alpha = 0.84
|
| 375 |
+
tile_width = max(128, image_width // 4)
|
| 376 |
+
else:
|
| 377 |
+
blend_alpha = 0.66
|
| 378 |
+
tile_width = max(128, image_width // 4)
|
| 379 |
+
|
| 380 |
+
return (surface_type, float(angle % 180.0), float(blend_alpha), int(tile_width))
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def choose_auto_texture_settings(material: str, surface_type: str) -> tuple[float, float, float]:
|
| 384 |
+
strength_map = {"acm": 0.98, "stone": 0.96, "wood": 0.88, "metal": 0.91, "generic": 0.9}
|
| 385 |
+
intensity_map = {"acm": 0.08, "stone": 0.36, "wood": 0.3, "metal": 0.34, "generic": 0.32}
|
| 386 |
+
|
| 387 |
+
strength = float(strength_map.get(material, 0.9))
|
| 388 |
+
intensity = float(intensity_map.get(material, 0.32))
|
| 389 |
+
|
| 390 |
+
if surface_type in {"wall", "facade"}:
|
| 391 |
+
strength += 0.02
|
| 392 |
+
angle = 28.0
|
| 393 |
+
elif surface_type in {"roof"}:
|
| 394 |
+
angle = 42.0
|
| 395 |
+
intensity += 0.03
|
| 396 |
+
elif surface_type in {"floor", "deck"}:
|
| 397 |
+
angle = 24.0
|
| 398 |
+
intensity += 0.02
|
| 399 |
+
else:
|
| 400 |
+
angle = 35.0
|
| 401 |
+
|
| 402 |
+
return (
|
| 403 |
+
float(np.clip(strength, 0.55, 0.99)),
|
| 404 |
+
float(angle % 360.0),
|
| 405 |
+
float(np.clip(intensity, 0.0, 1.0)),
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def build_feather_mask(binary_mask: np.ndarray, sigma: float = 2.2) -> np.ndarray:
|
| 410 |
+
mask = (binary_mask > 0).astype(np.float32)
|
| 411 |
+
if mask.max() <= 0:
|
| 412 |
+
return mask
|
| 413 |
+
feather = cv2.GaussianBlur(mask, (0, 0), sigmaX=sigma, sigmaY=sigma)
|
| 414 |
+
return np.clip(feather, 0.0, 1.0)
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def build_scene_luminance_map(orig_rgb: np.ndarray) -> np.ndarray:
|
| 418 |
+
orig_u8 = orig_rgb.astype(np.uint8)
|
| 419 |
+
orig_lab = cv2.cvtColor(orig_u8, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 420 |
+
l_channel = orig_lab[:, :, 0] / 255.0
|
| 421 |
+
broad_light = cv2.GaussianBlur(l_channel, (0, 0), sigmaX=18.0, sigmaY=18.0)
|
| 422 |
+
local_detail = l_channel - cv2.GaussianBlur(l_channel, (0, 0), sigmaX=4.0, sigmaY=4.0)
|
| 423 |
+
light_map = 0.82 + (broad_light * 0.36) + (local_detail * 0.18)
|
| 424 |
+
return np.clip(light_map, 0.72, 1.22)
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def build_texture_relief_map(tex_rgb: np.ndarray, material: str = "generic") -> np.ndarray:
|
| 428 |
+
tex_u8 = tex_rgb.astype(np.uint8)
|
| 429 |
+
tex_gray = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
|
| 430 |
+
micro_relief = tex_gray - cv2.GaussianBlur(tex_gray, (0, 0), sigmaX=3.0, sigmaY=3.0)
|
| 431 |
+
|
| 432 |
+
relief_scale = {"acm": 0.35, "stone": 2.8, "wood": 2.2, "metal": 1.8}.get(material, 2.0)
|
| 433 |
+
return np.clip(micro_relief * relief_scale, -1.0, 1.0)
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def build_directional_light_map(
|
| 437 |
+
tex_rgb: np.ndarray,
|
| 438 |
+
material: str,
|
| 439 |
+
light_angle_degrees: float,
|
| 440 |
+
light_intensity: float,
|
| 441 |
+
) -> np.ndarray:
|
| 442 |
+
tex_u8 = tex_rgb.astype(np.uint8)
|
| 443 |
+
tex_gray = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
|
| 444 |
+
height_map = cv2.GaussianBlur(tex_gray, (0, 0), sigmaX=1.4, sigmaY=1.4)
|
| 445 |
+
grad_x = cv2.Sobel(height_map, cv2.CV_32F, 1, 0, ksize=3)
|
| 446 |
+
grad_y = cv2.Sobel(height_map, cv2.CV_32F, 0, 1, ksize=3)
|
| 447 |
+
|
| 448 |
+
relief_scale = {"acm": 0.45, "stone": 3.0, "wood": 2.4, "metal": 1.6}.get(material, 2.0)
|
| 449 |
+
|
| 450 |
+
nx = -grad_x * relief_scale
|
| 451 |
+
ny = -grad_y * relief_scale
|
| 452 |
+
nz = np.ones_like(nx, dtype=np.float32)
|
| 453 |
+
norm = np.sqrt((nx * nx) + (ny * ny) + (nz * nz)) + 1e-6
|
| 454 |
+
nx = nx / norm
|
| 455 |
+
ny = ny / norm
|
| 456 |
+
nz = nz / norm
|
| 457 |
+
|
| 458 |
+
theta = np.deg2rad(float(light_angle_degrees))
|
| 459 |
+
lx = float(np.cos(theta))
|
| 460 |
+
ly = float(-np.sin(theta))
|
| 461 |
+
lz = 0.82
|
| 462 |
+
light_norm = max(1e-6, float(np.sqrt((lx * lx) + (ly * ly) + (lz * lz))))
|
| 463 |
+
lx /= light_norm
|
| 464 |
+
ly /= light_norm
|
| 465 |
+
lz /= light_norm
|
| 466 |
+
|
| 467 |
+
diffuse = np.clip((nx * lx) + (ny * ly) + (nz * lz), 0.0, 1.0)
|
| 468 |
+
strength = float(np.clip(light_intensity, 0.0, 1.0))
|
| 469 |
+
if material == "acm":
|
| 470 |
+
return np.clip(0.97 + (diffuse * (0.03 + (0.12 * strength))), 0.95, 1.12)
|
| 471 |
+
return np.clip(0.86 + (diffuse * (0.14 + (0.60 * strength))), 0.72, 1.35)
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def build_mask_edge_occlusion(binary_mask: np.ndarray, light_intensity: float) -> np.ndarray:
|
| 475 |
+
mask_u8 = (binary_mask > 0).astype(np.uint8)
|
| 476 |
+
if mask_u8.max() == 0:
|
| 477 |
+
return np.ones(mask_u8.shape, dtype=np.float32)
|
| 478 |
+
|
| 479 |
+
distance = cv2.distanceTransform(mask_u8, cv2.DIST_L2, 5).astype(np.float32)
|
| 480 |
+
inner_values = distance[mask_u8 > 0]
|
| 481 |
+
if inner_values.size == 0:
|
| 482 |
+
return np.ones(mask_u8.shape, dtype=np.float32)
|
| 483 |
+
|
| 484 |
+
max_distance = max(1.0, float(np.percentile(inner_values, 95)))
|
| 485 |
+
normalized = np.clip(distance / (max_distance * 0.16), 0.0, 1.0)
|
| 486 |
+
edge_strength = 1.0 - normalized
|
| 487 |
+
occlusion = 1.0 - (edge_strength * (0.04 + (0.08 * float(np.clip(light_intensity, 0.0, 1.0)))))
|
| 488 |
+
occlusion[mask_u8 == 0] = 1.0
|
| 489 |
+
return np.clip(occlusion, 0.88, 1.0)
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def apply_surface_lighting(
|
| 493 |
+
tex_rgb: np.ndarray,
|
| 494 |
+
orig_rgb: np.ndarray,
|
| 495 |
+
binary_mask: np.ndarray,
|
| 496 |
+
material: str,
|
| 497 |
+
lighting_mode: str,
|
| 498 |
+
light_angle_degrees: float,
|
| 499 |
+
light_intensity: float,
|
| 500 |
+
) -> np.ndarray:
|
| 501 |
+
scene_light = build_scene_luminance_map(orig_rgb)
|
| 502 |
+
directional_light = build_directional_light_map(tex_rgb, material, light_angle_degrees, light_intensity)
|
| 503 |
+
relief_map = build_texture_relief_map(tex_rgb, material)
|
| 504 |
+
edge_occlusion = build_mask_edge_occlusion(binary_mask, light_intensity)
|
| 505 |
+
|
| 506 |
+
if material == "acm":
|
| 507 |
+
if lighting_mode == "directional":
|
| 508 |
+
light_map = directional_light
|
| 509 |
+
elif lighting_mode == "flat":
|
| 510 |
+
light_map = np.ones(scene_light.shape, dtype=np.float32)
|
| 511 |
+
else:
|
| 512 |
+
# 45 % scene luminance so ACM panels inherit shadows/gradients from photo
|
| 513 |
+
light_map = (scene_light * 0.45) + (directional_light * 0.55)
|
| 514 |
+
elif lighting_mode == "directional":
|
| 515 |
+
light_map = directional_light
|
| 516 |
+
elif lighting_mode == "flat":
|
| 517 |
+
light_map = np.ones(scene_light.shape, dtype=np.float32)
|
| 518 |
+
else:
|
| 519 |
+
light_map = (scene_light * 0.78) + (directional_light * 0.22)
|
| 520 |
+
|
| 521 |
+
detail_scale = 0.02 + (0.05 * float(np.clip(light_intensity, 0.0, 1.0))) if material == "acm" else 0.08 + (0.18 * float(np.clip(light_intensity, 0.0, 1.0)))
|
| 522 |
+
detail_boost = 1.0 + (relief_map * detail_scale)
|
| 523 |
+
enhanced = tex_rgb.astype(np.float32)
|
| 524 |
+
enhanced *= light_map[:, :, None]
|
| 525 |
+
enhanced *= detail_boost[:, :, None]
|
| 526 |
+
enhanced *= edge_occlusion[:, :, None]
|
| 527 |
+
return np.clip(enhanced, 0, 255).astype(np.uint8)
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
def blend_texture_preserve_shading(
|
| 531 |
+
orig_rgb: np.ndarray,
|
| 532 |
+
tex_rgb: np.ndarray,
|
| 533 |
+
alpha_mask: np.ndarray,
|
| 534 |
+
blend_alpha: float,
|
| 535 |
+
material: str = "generic",
|
| 536 |
+
) -> np.ndarray:
|
| 537 |
+
orig_u8 = orig_rgb.astype(np.uint8)
|
| 538 |
+
tex_u8 = tex_rgb.astype(np.uint8)
|
| 539 |
+
|
| 540 |
+
orig_lab = cv2.cvtColor(orig_u8, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 541 |
+
tex_lab = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 542 |
+
|
| 543 |
+
mixed_lab = tex_lab.copy()
|
| 544 |
+
if material == "acm":
|
| 545 |
+
# 30 % original luminance → panels inherit scene shadows while keeping panel colour
|
| 546 |
+
mixed_lab[:, :, 0] = (0.30 * orig_lab[:, :, 0]) + (0.70 * tex_lab[:, :, 0])
|
| 547 |
+
mixed_lab[:, :, 1] = (0.97 * tex_lab[:, :, 1]) + (0.03 * orig_lab[:, :, 1])
|
| 548 |
+
mixed_lab[:, :, 2] = (0.97 * tex_lab[:, :, 2]) + (0.03 * orig_lab[:, :, 2])
|
| 549 |
+
elif material == "wood":
|
| 550 |
+
mixed_lab[:, :, 0] = (0.78 * orig_lab[:, :, 0]) + (0.22 * tex_lab[:, :, 0])
|
| 551 |
+
mixed_lab[:, :, 1] = (0.9 * tex_lab[:, :, 1]) + (0.1 * orig_lab[:, :, 1])
|
| 552 |
+
mixed_lab[:, :, 2] = (0.9 * tex_lab[:, :, 2]) + (0.1 * orig_lab[:, :, 2])
|
| 553 |
+
elif material == "stone":
|
| 554 |
+
orig_l_base = cv2.GaussianBlur(orig_lab[:, :, 0], (0, 0), sigmaX=11.0, sigmaY=11.0)
|
| 555 |
+
mixed_lab[:, :, 0] = (0.18 * orig_l_base) + (0.82 * tex_lab[:, :, 0])
|
| 556 |
+
mixed_lab[:, :, 1] = (0.95 * tex_lab[:, :, 1]) + (0.05 * orig_lab[:, :, 1])
|
| 557 |
+
mixed_lab[:, :, 2] = (0.95 * tex_lab[:, :, 2]) + (0.05 * orig_lab[:, :, 2])
|
| 558 |
+
else:
|
| 559 |
+
mixed_lab[:, :, 0] = orig_lab[:, :, 0]
|
| 560 |
+
mixed_lab[:, :, 1] = (0.8 * tex_lab[:, :, 1]) + (0.2 * orig_lab[:, :, 1])
|
| 561 |
+
mixed_lab[:, :, 2] = (0.8 * tex_lab[:, :, 2]) + (0.2 * orig_lab[:, :, 2])
|
| 562 |
+
|
| 563 |
+
shaded_tex = cv2.cvtColor(np.clip(mixed_lab, 0, 255).astype(np.uint8), cv2.COLOR_LAB2RGB).astype(np.float32)
|
| 564 |
+
if material == "wood":
|
| 565 |
+
tex_gray = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2GRAY).astype(np.float32)
|
| 566 |
+
tex_base = cv2.GaussianBlur(tex_gray, (0, 0), sigmaX=9.0, sigmaY=9.0)
|
| 567 |
+
tex_detail = np.clip((tex_gray - tex_base) / 255.0, -0.35, 0.35)
|
| 568 |
+
shaded_tex *= (1.0 + (tex_detail[:, :, None] * 0.28))
|
| 569 |
+
|
| 570 |
+
alpha = np.clip(alpha_mask[:, :, None] * float(blend_alpha), 0.0, 1.0)
|
| 571 |
+
composite = (orig_rgb * (1.0 - alpha)) + (shaded_tex * alpha)
|
| 572 |
+
return np.clip(composite, 0, 255).astype(np.uint8)
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
def blend_texture_direct(
|
| 576 |
+
orig_rgb: np.ndarray,
|
| 577 |
+
tex_rgb: np.ndarray,
|
| 578 |
+
alpha_mask: np.ndarray,
|
| 579 |
+
blend_alpha: float,
|
| 580 |
+
) -> np.ndarray:
|
| 581 |
+
alpha = np.clip(alpha_mask[:, :, None] * float(blend_alpha), 0.0, 1.0)
|
| 582 |
+
composite = (orig_rgb * (1.0 - alpha)) + (tex_rgb * alpha)
|
| 583 |
+
return np.clip(composite, 0, 255).astype(np.uint8)
|
| 584 |
+
|
| 585 |
+
|
| 586 |
+
def apply_local_texture_sync(payload: ApplyTextureRequest) -> dict[str, Any]:
|
| 587 |
+
step = "APPLY_TEXTURE"
|
| 588 |
+
started = log_timing_start(step)
|
| 589 |
+
try:
|
| 590 |
+
safe_name = Path(payload.filename).name
|
| 591 |
+
if not safe_name:
|
| 592 |
+
raise HTTPException(status_code=400, detail="Invalid filename")
|
| 593 |
+
|
| 594 |
+
label_safe_name = Path(payload.original_filename).name if payload.original_filename else safe_name
|
| 595 |
+
|
| 596 |
+
image_path = UPLOAD_DIR / safe_name
|
| 597 |
+
if not image_path.exists() or not image_path.is_file():
|
| 598 |
+
image_path = OUTPUT_DIR / safe_name
|
| 599 |
+
|
| 600 |
+
if (not image_path.exists() or not image_path.is_file()) and payload.original_filename:
|
| 601 |
+
orig_name = Path(payload.original_filename).name
|
| 602 |
+
image_path = UPLOAD_DIR / orig_name
|
| 603 |
+
if not image_path.exists() or not image_path.is_file():
|
| 604 |
+
image_path = OUTPUT_DIR / orig_name
|
| 605 |
+
|
| 606 |
+
if not image_path.exists() or not image_path.is_file():
|
| 607 |
+
raise HTTPException(
|
| 608 |
+
status_code=404,
|
| 609 |
+
detail=f"Image not found: {safe_name} (also tried original: {payload.original_filename or 'n/a'})",
|
| 610 |
+
)
|
| 611 |
+
|
| 612 |
+
masks_dir = UPLOAD_DIR / "masks"
|
| 613 |
+
masks_dir.mkdir(exist_ok=True)
|
| 614 |
+
label_owner = Path(image_path).stem
|
| 615 |
+
label_path = masks_dir / f"{label_owner}_labels.png"
|
| 616 |
+
if not label_path.exists() and payload.original_filename:
|
| 617 |
+
alt_owner = Path(payload.original_filename).name
|
| 618 |
+
alt_label = masks_dir / f"{alt_owner}_labels.png"
|
| 619 |
+
if alt_label.exists():
|
| 620 |
+
label_path = alt_label
|
| 621 |
+
|
| 622 |
+
if not label_path.exists():
|
| 623 |
+
raise HTTPException(
|
| 624 |
+
status_code=404,
|
| 625 |
+
detail=f"Label map not found for {label_owner}. Upload/segment the image first.",
|
| 626 |
+
)
|
| 627 |
+
|
| 628 |
+
if not payload.mask_indices:
|
| 629 |
+
raise HTTPException(status_code=400, detail="No mask indices provided")
|
| 630 |
+
|
| 631 |
+
texture_path = resolve_texture_path(payload.texture_name)
|
| 632 |
+
orig_pil = Image.open(str(image_path)).convert("RGB")
|
| 633 |
+
width, height = orig_pil.size
|
| 634 |
+
|
| 635 |
+
label_map = cv2.imread(str(label_path), cv2.IMREAD_GRAYSCALE)
|
| 636 |
+
if label_map is None:
|
| 637 |
+
raise HTTPException(status_code=500, detail="Could not read label map")
|
| 638 |
+
|
| 639 |
+
binary_mask = np.zeros((label_map.shape[0], label_map.shape[1]), dtype=np.uint8)
|
| 640 |
+
for idx in payload.mask_indices:
|
| 641 |
+
binary_mask |= (label_map == idx).astype(np.uint8)
|
| 642 |
+
|
| 643 |
+
if binary_mask.max() == 0:
|
| 644 |
+
raise HTTPException(status_code=400, detail="None of the selected segments were found in the label map.")
|
| 645 |
+
|
| 646 |
+
direction_mode = str(payload.direction_mode or "auto").strip().lower()
|
| 647 |
+
if direction_mode not in {"auto", "manual", "none"}:
|
| 648 |
+
raise HTTPException(status_code=400, detail="Invalid direction_mode. Use auto, manual, or none.")
|
| 649 |
+
|
| 650 |
+
replace_mode = str(getattr(payload, "replace_mode", "realistic") or "realistic").strip().lower()
|
| 651 |
+
lighting_mode = str(getattr(payload, "lighting_mode", "scene") or "scene").strip().lower()
|
| 652 |
+
|
| 653 |
+
material = classify_texture_material(payload.texture_name)
|
| 654 |
+
surface_type, inferred_angle, blend_alpha, target_w = infer_surface_type_and_direction(
|
| 655 |
+
binary_mask, width, height, payload.texture_name,
|
| 656 |
+
)
|
| 657 |
+
replace_strength, light_angle_degrees, light_intensity = choose_auto_texture_settings(material, surface_type)
|
| 658 |
+
effective_alpha = float(np.clip(blend_alpha * (0.55 + (0.75 * replace_strength)), 0.0, 0.98))
|
| 659 |
+
if material == "acm":
|
| 660 |
+
effective_alpha = float(max(effective_alpha, 0.92))
|
| 661 |
+
|
| 662 |
+
applied_angle = 0.0
|
| 663 |
+
if direction_mode == "auto":
|
| 664 |
+
applied_angle = inferred_angle
|
| 665 |
+
elif direction_mode == "manual":
|
| 666 |
+
applied_angle = float(payload.angle_degrees)
|
| 667 |
+
|
| 668 |
+
tex_pil = load_texture_pil_rgb(texture_path)
|
| 669 |
+
|
| 670 |
+
# Escalar al tamaño de tile deseado ANTES de rotar
|
| 671 |
+
tex_w, tex_h = tex_pil.size
|
| 672 |
+
scale = target_w / max(1, tex_w)
|
| 673 |
+
if abs(scale - 1.0) > 0.05:
|
| 674 |
+
tex_pil = tex_pil.resize(
|
| 675 |
+
(max(1, int(tex_w * scale)), max(1, int(tex_h * scale))),
|
| 676 |
+
Image.Resampling.LANCZOS,
|
| 677 |
+
)
|
| 678 |
+
tex_w, tex_h = tex_pil.size
|
| 679 |
+
|
| 680 |
+
tiled: Image.Image | None = None
|
| 681 |
+
|
| 682 |
+
# Floor surfaces: perspective tiling solo cuando la perspectiva es fuerte.
|
| 683 |
+
# Para pisos de dormitorio/habitación (perspectiva suave) el quad aproximado
|
| 684 |
+
# no cubre bien el mask irregular → produce negro. Se usa tiling regular en esos casos.
|
| 685 |
+
if surface_type == "floor" and direction_mode in {"auto", "none"}:
|
| 686 |
+
ys_m, xs_m = np.where(binary_mask > 0)
|
| 687 |
+
if ys_m.size > 0:
|
| 688 |
+
min_y_m, max_y_m = int(ys_m.min()), int(ys_m.max())
|
| 689 |
+
bbox_h_m = max(1, max_y_m - min_y_m + 1)
|
| 690 |
+
trap_score = _compute_trapezoid_score_from_mask(
|
| 691 |
+
binary_mask, ys_m, xs_m, min_y_m, max_y_m, bbox_h_m
|
| 692 |
+
)
|
| 693 |
+
if trap_score > 0.35:
|
| 694 |
+
quad = _extract_mask_quad(binary_mask)
|
| 695 |
+
if quad is not None:
|
| 696 |
+
tiled = _tile_texture_perspective(tex_pil, quad, width, height)
|
| 697 |
+
if tiled is not None:
|
| 698 |
+
logger.info(f"[APPLY_TEXTURE] perspective floor tiling applied (trap={trap_score:.2f})")
|
| 699 |
+
else:
|
| 700 |
+
logger.info(f"[APPLY_TEXTURE] flat floor tiling (trap={trap_score:.2f} < 0.35, skip perspective)")
|
| 701 |
+
|
| 702 |
+
# Wall / ceiling surfaces: perspective tiling cuando el quad es significativamente
|
| 703 |
+
# no-rectangular (pared fotografiada en ángulo). Un ratio > 1.20 entre lado mayor
|
| 704 |
+
# y lado menor indica distorsión perspectiva visible.
|
| 705 |
+
if surface_type in {"wall", "ceiling"} and tiled is None and direction_mode in {"auto", "none"}:
|
| 706 |
+
quad = _extract_mask_quad(binary_mask)
|
| 707 |
+
if quad is not None:
|
| 708 |
+
tl, tr, br, bl = quad
|
| 709 |
+
top_w = float(np.linalg.norm(tr.astype(float) - tl.astype(float)))
|
| 710 |
+
bot_w = float(np.linalg.norm(br.astype(float) - bl.astype(float)))
|
| 711 |
+
left_h = float(np.linalg.norm(bl.astype(float) - tl.astype(float)))
|
| 712 |
+
right_h = float(np.linalg.norm(br.astype(float) - tr.astype(float)))
|
| 713 |
+
w_ratio = max(top_w, bot_w) / max(1.0, min(top_w, bot_w))
|
| 714 |
+
h_ratio = max(left_h, right_h) / max(1.0, min(left_h, right_h))
|
| 715 |
+
if w_ratio > 1.20 or h_ratio > 1.20:
|
| 716 |
+
tiled = _tile_texture_perspective(tex_pil, quad, width, height)
|
| 717 |
+
if tiled is not None:
|
| 718 |
+
logger.info(
|
| 719 |
+
f"[APPLY_TEXTURE] perspective wall tiling applied "
|
| 720 |
+
f"(w_ratio={w_ratio:.2f}, h_ratio={h_ratio:.2f})"
|
| 721 |
+
)
|
| 722 |
+
|
| 723 |
+
if tiled is None:
|
| 724 |
+
if abs(applied_angle) > 0.01:
|
| 725 |
+
# Tile on a large canvas first, then rotate the full canvas to avoid black corners
|
| 726 |
+
diag = int(np.ceil(np.sqrt(width ** 2 + height ** 2))) + max(tex_w, tex_h)
|
| 727 |
+
large_w = width + diag
|
| 728 |
+
large_h = height + diag
|
| 729 |
+
large = Image.new("RGB", (large_w, large_h))
|
| 730 |
+
for y in range(0, large_h, tex_h):
|
| 731 |
+
for x in range(0, large_w, tex_w):
|
| 732 |
+
large.paste(tex_pil, (x, y))
|
| 733 |
+
large = large.rotate(-applied_angle, resample=Image.Resampling.BICUBIC, expand=False)
|
| 734 |
+
cx = (large_w - width) // 2
|
| 735 |
+
cy = (large_h - height) // 2
|
| 736 |
+
tiled = large.crop((cx, cy, cx + width, cy + height))
|
| 737 |
+
else:
|
| 738 |
+
tiled = Image.new("RGB", (width, height))
|
| 739 |
+
for y in range(0, height, tex_h):
|
| 740 |
+
for x in range(0, width, tex_w):
|
| 741 |
+
tiled.paste(tex_pil, (x, y))
|
| 742 |
+
|
| 743 |
+
orig_u8 = np.array(orig_pil, dtype=np.uint8)
|
| 744 |
+
|
| 745 |
+
try:
|
| 746 |
+
if bool(getattr(payload, "clear_mask_before_apply", False)):
|
| 747 |
+
orig_candidate = None
|
| 748 |
+
if payload.original_filename:
|
| 749 |
+
cand = UPLOAD_DIR / Path(payload.original_filename).name
|
| 750 |
+
if cand.exists() and cand.is_file():
|
| 751 |
+
orig_candidate = cand
|
| 752 |
+
else:
|
| 753 |
+
cand2 = OUTPUT_DIR / Path(payload.original_filename).name
|
| 754 |
+
if cand2.exists() and cand2.is_file():
|
| 755 |
+
orig_candidate = cand2
|
| 756 |
+
if orig_candidate is None:
|
| 757 |
+
stem = Path(image_path).stem
|
| 758 |
+
if "_edit_" in stem:
|
| 759 |
+
base_name = stem.split("_edit_")[0] + ".jpg"
|
| 760 |
+
cand = UPLOAD_DIR / base_name
|
| 761 |
+
if cand.exists() and cand.is_file():
|
| 762 |
+
orig_candidate = cand
|
| 763 |
+
else:
|
| 764 |
+
cand2 = OUTPUT_DIR / base_name
|
| 765 |
+
if cand2.exists() and cand2.is_file():
|
| 766 |
+
orig_candidate = cand2
|
| 767 |
+
|
| 768 |
+
if orig_candidate is not None:
|
| 769 |
+
try:
|
| 770 |
+
base_pil = Image.open(str(orig_candidate)).convert("RGB")
|
| 771 |
+
if base_pil.size != (width, height):
|
| 772 |
+
base_pil = base_pil.resize((width, height), Image.Resampling.LANCZOS)
|
| 773 |
+
base_arr = np.array(base_pil, dtype=np.uint8)
|
| 774 |
+
mask_bool = (binary_mask > 0)
|
| 775 |
+
orig_u8[mask_bool] = base_arr[mask_bool]
|
| 776 |
+
logger.info(f"[APPLY_TEXTURE] cleared mask from original source: {orig_candidate}")
|
| 777 |
+
except Exception:
|
| 778 |
+
logger.exception("Failed to restore original pixels for clear_mask_before_apply")
|
| 779 |
+
except Exception:
|
| 780 |
+
logger.exception("Error handling clear_mask_before_apply")
|
| 781 |
+
|
| 782 |
+
tiled_arr = np.array(tiled, dtype=np.uint8)
|
| 783 |
+
|
| 784 |
+
# Parchar píxeles muy oscuros (suma R+G+B < 20) dentro de la máscara.
|
| 785 |
+
# Cubren tanto negro exacto (0,0,0) como píxeles casi negros que el warp
|
| 786 |
+
# de perspectiva puede generar en bordes del quad o zonas sin cobertura.
|
| 787 |
+
mask_bool = binary_mask > 0
|
| 788 |
+
dark_in_mask = mask_bool & (tiled_arr.sum(axis=2) < 20)
|
| 789 |
+
if dark_in_mask.any():
|
| 790 |
+
th_f, tw_f = tex_pil.size[1], tex_pil.size[0]
|
| 791 |
+
tex_np = np.array(tex_pil, dtype=np.uint8)
|
| 792 |
+
ys_b, xs_b = np.where(dark_in_mask)
|
| 793 |
+
tiled_arr[ys_b, xs_b] = tex_np[ys_b % th_f, xs_b % tw_f]
|
| 794 |
+
|
| 795 |
+
lit_tex = apply_surface_lighting(
|
| 796 |
+
tiled_arr,
|
| 797 |
+
orig_u8,
|
| 798 |
+
binary_mask,
|
| 799 |
+
material,
|
| 800 |
+
lighting_mode,
|
| 801 |
+
light_angle_degrees,
|
| 802 |
+
light_intensity,
|
| 803 |
+
)
|
| 804 |
+
|
| 805 |
+
orig_arr = orig_u8.astype(np.float32)
|
| 806 |
+
tex_arr = lit_tex.astype(np.float32)
|
| 807 |
+
|
| 808 |
+
if replace_mode in {"hard", "absolute", "force", "replace"}:
|
| 809 |
+
mask_bool = (binary_mask > 0).astype(bool)
|
| 810 |
+
composite_arr = orig_arr.copy()
|
| 811 |
+
composite_arr[mask_bool] = tex_arr[mask_bool]
|
| 812 |
+
composite = np.clip(composite_arr, 0, 255).astype(np.uint8)
|
| 813 |
+
else:
|
| 814 |
+
feather_mask = build_feather_mask(binary_mask)
|
| 815 |
+
# All materials use shading-preservation so scene luminance (shadows/highlights)
|
| 816 |
+
# from the original photo is transferred onto the texture.
|
| 817 |
+
composite = blend_texture_preserve_shading(orig_arr, tex_arr, feather_mask, effective_alpha, material)
|
| 818 |
+
|
| 819 |
+
input_stem = Path(image_path).stem
|
| 820 |
+
edit_suffix = uuid.uuid4().hex[:8]
|
| 821 |
+
out_filename = f"{input_stem}_edit_{edit_suffix}.jpg"
|
| 822 |
+
out_path = UPLOAD_DIR / out_filename
|
| 823 |
+
Image.fromarray(composite).save(str(out_path), format="JPEG", quality=UPLOAD_JPEG_QUALITY, optimize=True)
|
| 824 |
+
|
| 825 |
+
try:
|
| 826 |
+
out_label_path = masks_dir / f"{Path(out_filename).stem}_labels.png"
|
| 827 |
+
if label_path.exists():
|
| 828 |
+
shutil.copyfile(str(label_path), str(out_label_path))
|
| 829 |
+
except Exception:
|
| 830 |
+
logger.exception("Failed to copy label map for output image")
|
| 831 |
+
|
| 832 |
+
return {
|
| 833 |
+
"message": "Texture applied successfully",
|
| 834 |
+
"original": safe_name,
|
| 835 |
+
"mask_indices": payload.mask_indices,
|
| 836 |
+
"texture_name": payload.texture_name,
|
| 837 |
+
"material": material,
|
| 838 |
+
"direction_mode": direction_mode,
|
| 839 |
+
"surface_type": surface_type,
|
| 840 |
+
"replace_mode": replace_mode,
|
| 841 |
+
"replace_strength": round(replace_strength, 3),
|
| 842 |
+
"lighting_mode": lighting_mode,
|
| 843 |
+
"light_angle_degrees": round(light_angle_degrees, 2),
|
| 844 |
+
"light_intensity": round(light_intensity, 3),
|
| 845 |
+
"blend_alpha": round(effective_alpha, 3),
|
| 846 |
+
"applied_angle_degrees": round(applied_angle, 2),
|
| 847 |
+
"output_filename": out_filename,
|
| 848 |
+
"output_url": f"/seg/image/{out_filename}",
|
| 849 |
+
}
|
| 850 |
+
finally:
|
| 851 |
+
log_timing_end(step, started)
|
backend/templates/classic_dashboard.html
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/texturas/Texture_wpc_deck/DECK_gris.png
ADDED
|
Git LFS Details
|
backend/texturas/Texture_wpc_deck/DECK_madera.png
ADDED
|
Git LFS Details
|
backend/texturas/Texture_wpc_deck/DECK_madera_oscuro.png
ADDED
|
Git LFS Details
|
backend/visualizador.html
CHANGED
|
@@ -1,125 +1,125 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html lang="es">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8" />
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
-
<title>Visualizador SaaS fasdfadsf</title>
|
| 7 |
-
<script
|
| 8 |
-
src="https://unpkg.com/react@18/umd/react.development.js"
|
| 9 |
-
crossorigin
|
| 10 |
-
></script>
|
| 11 |
-
<script
|
| 12 |
-
src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"
|
| 13 |
-
crossorigin
|
| 14 |
-
></script>
|
| 15 |
-
<script src="https://cdn.tailwindcss.com"></script>
|
| 16 |
-
</head>
|
| 17 |
-
<body class="bg-slate-100 text-slate-900">
|
| 18 |
-
<div
|
| 19 |
-
id="root"
|
| 20 |
-
class="min-h-screen flex items-center justify-center p-4"
|
| 21 |
-
></div>
|
| 22 |
-
|
| 23 |
-
<script>
|
| 24 |
-
const { useState, useEffect } = React;
|
| 25 |
-
|
| 26 |
-
function App() {
|
| 27 |
-
const [cliente, setCliente] = useState(null);
|
| 28 |
-
const [error, setError] = useState(null);
|
| 29 |
-
const [apiKey, setApiKey] = useState(null);
|
| 30 |
-
|
| 31 |
-
useEffect(() => {
|
| 32 |
-
const params = new URLSearchParams(window.location.search);
|
| 33 |
-
const token = params.get("token");
|
| 34 |
-
|
| 35 |
-
if (!token) {
|
| 36 |
-
setError("No se encontró el parámetro 'token' en la URL.");
|
| 37 |
-
return;
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
const url = `/config?token=${encodeURIComponent(token)}`;
|
| 41 |
-
|
| 42 |
-
fetch(url)
|
| 43 |
-
.then((res) => {
|
| 44 |
-
if (!res.ok) {
|
| 45 |
-
throw new Error("Error al obtener configuración del cliente");
|
| 46 |
-
}
|
| 47 |
-
return res.json();
|
| 48 |
-
})
|
| 49 |
-
.then((data) => {
|
| 50 |
-
setApiKey(data.client_id);
|
| 51 |
-
setCliente(data);
|
| 52 |
-
return fetch("/session/start", {
|
| 53 |
-
method: "POST",
|
| 54 |
-
headers: {
|
| 55 |
-
"Content-Type": "application/x-www-form-urlencoded",
|
| 56 |
-
},
|
| 57 |
-
body: `token=${encodeURIComponent(token)}`,
|
| 58 |
-
});
|
| 59 |
-
})
|
| 60 |
-
.catch((err) => setError(err.message));
|
| 61 |
-
}, []);
|
| 62 |
-
|
| 63 |
-
const enviarMensaje = () => {
|
| 64 |
-
if (!cliente) return;
|
| 65 |
-
window.parent.postMessage(
|
| 66 |
-
{
|
| 67 |
-
type: "saas-session-active",
|
| 68 |
-
nombreCliente: cliente.nombre,
|
| 69 |
-
},
|
| 70 |
-
"*",
|
| 71 |
-
);
|
| 72 |
-
};
|
| 73 |
-
|
| 74 |
-
return React.createElement(
|
| 75 |
-
"div",
|
| 76 |
-
{
|
| 77 |
-
className:
|
| 78 |
-
"w-full max-w-3xl bg-white rounded-3xl shadow-xl p-8 space-y-6 text-center",
|
| 79 |
-
},
|
| 80 |
-
React.createElement(
|
| 81 |
-
"h1",
|
| 82 |
-
{ className: "text-3xl font-bold text-slate-900" },
|
| 83 |
-
"Panel de Control:",
|
| 84 |
-
),
|
| 85 |
-
apiKey &&
|
| 86 |
-
React.createElement(
|
| 87 |
-
"p",
|
| 88 |
-
{ className: "text-sm text-slate-500" },
|
| 89 |
-
`API key usada: ${apiKey}`,
|
| 90 |
-
),
|
| 91 |
-
cliente
|
| 92 |
-
? React.createElement(
|
| 93 |
-
"p",
|
| 94 |
-
{ className: "text-xl text-slate-700" },
|
| 95 |
-
cliente.nombre,
|
| 96 |
-
)
|
| 97 |
-
: React.createElement(
|
| 98 |
-
"p",
|
| 99 |
-
{ className: "text-xl text-slate-500" },
|
| 100 |
-
error || "Cargando datos...",
|
| 101 |
-
),
|
| 102 |
-
React.createElement(
|
| 103 |
-
"button",
|
| 104 |
-
{
|
| 105 |
-
className:
|
| 106 |
-
"px-6 py-3 bg-blue-600 text-white rounded-xl shadow hover:bg-blue-700 transition",
|
| 107 |
-
onClick: enviarMensaje,
|
| 108 |
-
disabled: !cliente,
|
| 109 |
-
},
|
| 110 |
-
"Notificar al sitio padre",
|
| 111 |
-
),
|
| 112 |
-
cliente &&
|
| 113 |
-
React.createElement(
|
| 114 |
-
"div",
|
| 115 |
-
{ className: "mt-4 text-sm text-slate-500" },
|
| 116 |
-
`Color primario: ${cliente.color_primario}`,
|
| 117 |
-
),
|
| 118 |
-
);
|
| 119 |
-
}
|
| 120 |
-
|
| 121 |
-
const root = ReactDOM.createRoot(document.getElementById("root"));
|
| 122 |
-
root.render(React.createElement(App));
|
| 123 |
-
</script>
|
| 124 |
-
</body>
|
| 125 |
-
</html>
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="es">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Visualizador SaaS fasdfadsf</title>
|
| 7 |
+
<script
|
| 8 |
+
src="https://unpkg.com/react@18/umd/react.development.js"
|
| 9 |
+
crossorigin
|
| 10 |
+
></script>
|
| 11 |
+
<script
|
| 12 |
+
src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"
|
| 13 |
+
crossorigin
|
| 14 |
+
></script>
|
| 15 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 16 |
+
</head>
|
| 17 |
+
<body class="bg-slate-100 text-slate-900">
|
| 18 |
+
<div
|
| 19 |
+
id="root"
|
| 20 |
+
class="min-h-screen flex items-center justify-center p-4"
|
| 21 |
+
></div>
|
| 22 |
+
|
| 23 |
+
<script>
|
| 24 |
+
const { useState, useEffect } = React;
|
| 25 |
+
|
| 26 |
+
function App() {
|
| 27 |
+
const [cliente, setCliente] = useState(null);
|
| 28 |
+
const [error, setError] = useState(null);
|
| 29 |
+
const [apiKey, setApiKey] = useState(null);
|
| 30 |
+
|
| 31 |
+
useEffect(() => {
|
| 32 |
+
const params = new URLSearchParams(window.location.search);
|
| 33 |
+
const token = params.get("token");
|
| 34 |
+
|
| 35 |
+
if (!token) {
|
| 36 |
+
setError("No se encontró el parámetro 'token' en la URL.");
|
| 37 |
+
return;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const url = `/config?token=${encodeURIComponent(token)}`;
|
| 41 |
+
|
| 42 |
+
fetch(url)
|
| 43 |
+
.then((res) => {
|
| 44 |
+
if (!res.ok) {
|
| 45 |
+
throw new Error("Error al obtener configuración del cliente");
|
| 46 |
+
}
|
| 47 |
+
return res.json();
|
| 48 |
+
})
|
| 49 |
+
.then((data) => {
|
| 50 |
+
setApiKey(data.client_id);
|
| 51 |
+
setCliente(data);
|
| 52 |
+
return fetch("/session/start", {
|
| 53 |
+
method: "POST",
|
| 54 |
+
headers: {
|
| 55 |
+
"Content-Type": "application/x-www-form-urlencoded",
|
| 56 |
+
},
|
| 57 |
+
body: `token=${encodeURIComponent(token)}`,
|
| 58 |
+
});
|
| 59 |
+
})
|
| 60 |
+
.catch((err) => setError(err.message));
|
| 61 |
+
}, []);
|
| 62 |
+
|
| 63 |
+
const enviarMensaje = () => {
|
| 64 |
+
if (!cliente) return;
|
| 65 |
+
window.parent.postMessage(
|
| 66 |
+
{
|
| 67 |
+
type: "saas-session-active",
|
| 68 |
+
nombreCliente: cliente.nombre,
|
| 69 |
+
},
|
| 70 |
+
"*",
|
| 71 |
+
);
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
return React.createElement(
|
| 75 |
+
"div",
|
| 76 |
+
{
|
| 77 |
+
className:
|
| 78 |
+
"w-full max-w-3xl bg-white rounded-3xl shadow-xl p-8 space-y-6 text-center",
|
| 79 |
+
},
|
| 80 |
+
React.createElement(
|
| 81 |
+
"h1",
|
| 82 |
+
{ className: "text-3xl font-bold text-slate-900" },
|
| 83 |
+
"Panel de Control:",
|
| 84 |
+
),
|
| 85 |
+
apiKey &&
|
| 86 |
+
React.createElement(
|
| 87 |
+
"p",
|
| 88 |
+
{ className: "text-sm text-slate-500" },
|
| 89 |
+
`API key usada: ${apiKey}`,
|
| 90 |
+
),
|
| 91 |
+
cliente
|
| 92 |
+
? React.createElement(
|
| 93 |
+
"p",
|
| 94 |
+
{ className: "text-xl text-slate-700" },
|
| 95 |
+
cliente.nombre,
|
| 96 |
+
)
|
| 97 |
+
: React.createElement(
|
| 98 |
+
"p",
|
| 99 |
+
{ className: "text-xl text-slate-500" },
|
| 100 |
+
error || "Cargando datos...",
|
| 101 |
+
),
|
| 102 |
+
React.createElement(
|
| 103 |
+
"button",
|
| 104 |
+
{
|
| 105 |
+
className:
|
| 106 |
+
"px-6 py-3 bg-blue-600 text-white rounded-xl shadow hover:bg-blue-700 transition",
|
| 107 |
+
onClick: enviarMensaje,
|
| 108 |
+
disabled: !cliente,
|
| 109 |
+
},
|
| 110 |
+
"Notificar al sitio padre",
|
| 111 |
+
),
|
| 112 |
+
cliente &&
|
| 113 |
+
React.createElement(
|
| 114 |
+
"div",
|
| 115 |
+
{ className: "mt-4 text-sm text-slate-500" },
|
| 116 |
+
`Color primario: ${cliente.color_primario}`,
|
| 117 |
+
),
|
| 118 |
+
);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
const root = ReactDOM.createRoot(document.getElementById("root"));
|
| 122 |
+
root.render(React.createElement(App));
|
| 123 |
+
</script>
|
| 124 |
+
</body>
|
| 125 |
+
</html>
|
frontend/.gitignore
CHANGED
|
@@ -1,24 +1,24 @@
|
|
| 1 |
-
# Logs
|
| 2 |
-
logs
|
| 3 |
-
*.log
|
| 4 |
-
npm-debug.log*
|
| 5 |
-
yarn-debug.log*
|
| 6 |
-
yarn-error.log*
|
| 7 |
-
pnpm-debug.log*
|
| 8 |
-
lerna-debug.log*
|
| 9 |
-
|
| 10 |
-
node_modules
|
| 11 |
-
dist
|
| 12 |
-
dist-ssr
|
| 13 |
-
*.local
|
| 14 |
-
|
| 15 |
-
# Editor directories and files
|
| 16 |
-
.vscode/*
|
| 17 |
-
!.vscode/extensions.json
|
| 18 |
-
.idea
|
| 19 |
-
.DS_Store
|
| 20 |
-
*.suo
|
| 21 |
-
*.ntvs*
|
| 22 |
-
*.njsproj
|
| 23 |
-
*.sln
|
| 24 |
-
*.sw?
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
frontend/FRONTEND_DOCUMENTATION.md
CHANGED
|
@@ -1,250 +1,250 @@
|
|
| 1 |
-
# Documentación del Frontend
|
| 2 |
-
|
| 3 |
-
Este documento describe la arquitectura, las rutas, los estados, los hooks y los componentes principales del frontend React + TypeScript ubicado en `frontend/`.
|
| 4 |
-
|
| 5 |
-
---
|
| 6 |
-
|
| 7 |
-
## 1. Visión general
|
| 8 |
-
|
| 9 |
-
El frontend está construido con:
|
| 10 |
-
|
| 11 |
-
- React 19
|
| 12 |
-
- TypeScript
|
| 13 |
-
- Vite
|
| 14 |
-
- React Router DOM
|
| 15 |
-
- Zustand para estado global
|
| 16 |
-
- @tanstack/react-query para consultas asíncronas
|
| 17 |
-
- Tailwind CSS para estilos
|
| 18 |
-
- Arquitectura basada en características (`features`)
|
| 19 |
-
|
| 20 |
-
La carpeta principal de la aplicación es `frontend/src`.
|
| 21 |
-
|
| 22 |
-
---
|
| 23 |
-
|
| 24 |
-
## 2. Estructura de carpetas clave
|
| 25 |
-
|
| 26 |
-
- `src/main.tsx` - punto de entrada, configuración de router y React Query
|
| 27 |
-
- `src/App.tsx` - rutas principales de la aplicación
|
| 28 |
-
- `src/store` - estado global con Zustand
|
| 29 |
-
- `src/api` - cliente y funciones de API centralizadas
|
| 30 |
-
- `src/hooks` - hooks reutilizables del frontend
|
| 31 |
-
- `src/features` - features organizadas por dominio
|
| 32 |
-
- `src/utils` - utilidades compartidas
|
| 33 |
-
- `src/types.ts` - tipos globales de la aplicación
|
| 34 |
-
|
| 35 |
-
---
|
| 36 |
-
|
| 37 |
-
## 3. Rutas principales
|
| 38 |
-
|
| 39 |
-
`src/App.tsx` define las rutas:
|
| 40 |
-
|
| 41 |
-
- `/` → `RoomSetup`
|
| 42 |
-
- `/visualizer` → `RoomVisualizer`
|
| 43 |
-
- `/settings` → `SettingsPage`
|
| 44 |
-
- `*` → redirección a `/`
|
| 45 |
-
|
| 46 |
-
---
|
| 47 |
-
|
| 48 |
-
## 4. Estado global (Zustand)
|
| 49 |
-
|
| 50 |
-
`src/store/useAppStore.ts` almacena:
|
| 51 |
-
|
| 52 |
-
- `previewImage` - URL de la imagen cargada
|
| 53 |
-
- `uploadMessage` - estado de mensaje de subida
|
| 54 |
-
- `openProductId` - producto seleccionado en `RoomVisualizer`
|
| 55 |
-
- `viewMode` - vista actual de productos en el visualizador (`grid` o `list`)
|
| 56 |
-
|
| 57 |
-
Funciones disponibles:
|
| 58 |
-
|
| 59 |
-
- `setPreviewImage`
|
| 60 |
-
- `setUploadMessage`
|
| 61 |
-
- `setOpenProductId`
|
| 62 |
-
- `setViewMode`
|
| 63 |
-
- `reset`
|
| 64 |
-
|
| 65 |
-
---
|
| 66 |
-
|
| 67 |
-
## 5. Cliente API centralizado
|
| 68 |
-
|
| 69 |
-
`src/api/client.ts` incluye:
|
| 70 |
-
|
| 71 |
-
- `DEV_API_BASE` y `API_BASE` para la URL del backend
|
| 72 |
-
- `getApiBase` / `buildApiUrl` para construir endpoints
|
| 73 |
-
- `fetchClientConfig` para obtener configuración de cliente
|
| 74 |
-
- `uploadRoomImage` para subir imágenes al servidor
|
| 75 |
-
- `startSession` para iniciar una sesión remota
|
| 76 |
-
|
| 77 |
-
Esta capa permite mantener la URL del backend en un solo lugar y soportar un token de desarrollo automático.
|
| 78 |
-
|
| 79 |
-
---
|
| 80 |
-
|
| 81 |
-
## 6. Hooks compartidos
|
| 82 |
-
|
| 83 |
-
### `src/hooks/useUploadImage.ts`
|
| 84 |
-
|
| 85 |
-
- Maneja la subida de imágenes al backend
|
| 86 |
-
- Controla `isUploading` y `uploadError`
|
| 87 |
-
- Envuelve `uploadRoomImage` del cliente API
|
| 88 |
-
|
| 89 |
-
---
|
| 90 |
-
|
| 91 |
-
## 7. Tipos globales
|
| 92 |
-
|
| 93 |
-
`src/types.ts` declara tipos comunes como:
|
| 94 |
-
|
| 95 |
-
- `ClientData`
|
| 96 |
-
- `RouteItem`
|
| 97 |
-
- `Product`
|
| 98 |
-
|
| 99 |
-
El tipo `Product` se usa en el visualizador para productos de catálogo.
|
| 100 |
-
|
| 101 |
-
---
|
| 102 |
-
|
| 103 |
-
## 8. Feature: Room Setup
|
| 104 |
-
|
| 105 |
-
### Archivos
|
| 106 |
-
|
| 107 |
-
- `src/features/roomSetup/RoomSetup.tsx`
|
| 108 |
-
- `src/features/roomSetup/roomSetupHooks.ts`
|
| 109 |
-
- `src/features/roomSetup/RoomSetupComponents.tsx`
|
| 110 |
-
- `src/data/roomSetupData.ts`
|
| 111 |
-
|
| 112 |
-
### Comportamiento
|
| 113 |
-
|
| 114 |
-
`RoomSetup` es la página principal donde el usuario puede:
|
| 115 |
-
|
| 116 |
-
- subir una imagen arrastrando o seleccionando archivo
|
| 117 |
-
- ver una vista previa de la imagen
|
| 118 |
-
- iniciar la navegación hacia el visualizador
|
| 119 |
-
- ver habitaciones de demostración filtrables
|
| 120 |
-
|
| 121 |
-
### `useRoomSetup`
|
| 122 |
-
|
| 123 |
-
Este hook abstrae toda la lógica de:
|
| 124 |
-
|
| 125 |
-
- drag & drop
|
| 126 |
-
- selección de archivos
|
| 127 |
-
- subida de imagen
|
| 128 |
-
- gestión de estados de carga
|
| 129 |
-
- navegación con `useNavigate`
|
| 130 |
-
|
| 131 |
-
### Componentes auxiliares
|
| 132 |
-
|
| 133 |
-
- `FilterButton` - botón de filtrado por categoría
|
| 134 |
-
- `RoomCard` - tarjeta de habitación de demostración
|
| 135 |
-
|
| 136 |
-
---
|
| 137 |
-
|
| 138 |
-
## 9. Feature: Room Visualizer
|
| 139 |
-
|
| 140 |
-
### Archivos
|
| 141 |
-
|
| 142 |
-
- `src/features/roomVisualizer/RoomVisualizer.tsx`
|
| 143 |
-
- `src/features/roomVisualizer/roomVisualizerHooks.ts`
|
| 144 |
-
- `src/features/roomVisualizer/roomVisualizerData.ts`
|
| 145 |
-
- `src/features/roomVisualizer/ProductCards.tsx`
|
| 146 |
-
|
| 147 |
-
### Comportamiento
|
| 148 |
-
|
| 149 |
-
`RoomVisualizer` muestra:
|
| 150 |
-
|
| 151 |
-
- la imagen subida por el usuario
|
| 152 |
-
- controles de zoom y arrastre de la imagen
|
| 153 |
-
- lista / grid de productos para aplicar en la habitación
|
| 154 |
-
- selección de producto y detalles asociados
|
| 155 |
-
|
| 156 |
-
### `useRoomVisualizer`
|
| 157 |
-
|
| 158 |
-
Extrae la lógica de visualización de productos:
|
| 159 |
-
|
| 160 |
-
- modo de vista (`grid` / `list`)
|
| 161 |
-
- búsqueda y filtros básicos
|
| 162 |
-
- producto seleccionado
|
| 163 |
-
- manejo de selección de producto
|
| 164 |
-
- agrupación en chunks para renderizar grillas
|
| 165 |
-
|
| 166 |
-
### Datos de productos
|
| 167 |
-
|
| 168 |
-
- `roomVisualizerData.ts` contiene el catálogo de productos estáticos usados en la vista.
|
| 169 |
-
|
| 170 |
-
### Componentes
|
| 171 |
-
|
| 172 |
-
- `ProductGroupCard` - renderiza un grupo de 3 productos en grid
|
| 173 |
-
- `IndividualProductCard` - renderiza un producto en modo lista
|
| 174 |
-
|
| 175 |
-
---
|
| 176 |
-
|
| 177 |
-
## 10. Feature: Settings
|
| 178 |
-
|
| 179 |
-
### Archivo
|
| 180 |
-
|
| 181 |
-
- `src/features/settings/SettingsPage.tsx`
|
| 182 |
-
|
| 183 |
-
### Comportamiento
|
| 184 |
-
|
| 185 |
-
La página de configuración permite al usuario:
|
| 186 |
-
|
| 187 |
-
- definir la URL base de la API
|
| 188 |
-
- activar / desactivar la lista de rutas en el viewer
|
| 189 |
-
- definir token de desarrollo automático
|
| 190 |
-
|
| 191 |
-
### Soporte de almacenamiento
|
| 192 |
-
|
| 193 |
-
`src/utils/settings.ts` gestiona:
|
| 194 |
-
|
| 195 |
-
- carga de `localStorage`
|
| 196 |
-
- guardado de configuración
|
| 197 |
-
- valores por defecto
|
| 198 |
-
|
| 199 |
-
---
|
| 200 |
-
|
| 201 |
-
## 11. Componentes comunes
|
| 202 |
-
|
| 203 |
-
- `src/components/ui/LoadingScreen.tsx` - pantalla de carga reutilizable
|
| 204 |
-
|
| 205 |
-
---
|
| 206 |
-
|
| 207 |
-
## 12. Flujo principal de la app
|
| 208 |
-
|
| 209 |
-
1. Usuario entra en `/`
|
| 210 |
-
2. Sube una imagen en `RoomSetup`
|
| 211 |
-
3. Se crea una vista previa y se almacena en Zustand
|
| 212 |
-
4. Se navega a `/visualizer`
|
| 213 |
-
5. `RoomVisualizer` usa la imagen y muestra productos
|
| 214 |
-
6. El usuario filtra, busca y selecciona productos
|
| 215 |
-
7. `SettingsPage` permite ajustar la API y opciones de la app
|
| 216 |
-
|
| 217 |
-
---
|
| 218 |
-
|
| 219 |
-
## 13. Cómo ejecutar el frontend
|
| 220 |
-
|
| 221 |
-
Desde `frontend/`:
|
| 222 |
-
|
| 223 |
-
```bash
|
| 224 |
-
npm install
|
| 225 |
-
npm run dev
|
| 226 |
-
```
|
| 227 |
-
|
| 228 |
-
Construcción de producción:
|
| 229 |
-
|
| 230 |
-
```bash
|
| 231 |
-
npm run build
|
| 232 |
-
```
|
| 233 |
-
|
| 234 |
-
---
|
| 235 |
-
|
| 236 |
-
## 14. Posibles mejoras futuras
|
| 237 |
-
|
| 238 |
-
- mover `roomVisualizerProducts` a una API real o servicio de datos
|
| 239 |
-
- extraer más componentes presentacionales del visualizador
|
| 240 |
-
- usar React Query para cargar configuración del cliente en lugar de gestión manual
|
| 241 |
-
- añadir tests unitarios para hooks y componentes
|
| 242 |
-
- normalizar rutas y nombres de features
|
| 243 |
-
|
| 244 |
-
---
|
| 245 |
-
|
| 246 |
-
## 15. Observaciones adicionales
|
| 247 |
-
|
| 248 |
-
- La base API se controla desde `VITE_API_BASE_URL` o `DEV_API_BASE` en desarrollo.
|
| 249 |
-
- La aplicación usa `queryClient` de React Query, aunque hoy solo se usa para potenciales fetches futuros.
|
| 250 |
-
- El estado global es ligero y se usa principalmente para compartir imagen previa, modo de vista y selección de producto.
|
|
|
|
| 1 |
+
# Documentación del Frontend
|
| 2 |
+
|
| 3 |
+
Este documento describe la arquitectura, las rutas, los estados, los hooks y los componentes principales del frontend React + TypeScript ubicado en `frontend/`.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## 1. Visión general
|
| 8 |
+
|
| 9 |
+
El frontend está construido con:
|
| 10 |
+
|
| 11 |
+
- React 19
|
| 12 |
+
- TypeScript
|
| 13 |
+
- Vite
|
| 14 |
+
- React Router DOM
|
| 15 |
+
- Zustand para estado global
|
| 16 |
+
- @tanstack/react-query para consultas asíncronas
|
| 17 |
+
- Tailwind CSS para estilos
|
| 18 |
+
- Arquitectura basada en características (`features`)
|
| 19 |
+
|
| 20 |
+
La carpeta principal de la aplicación es `frontend/src`.
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
## 2. Estructura de carpetas clave
|
| 25 |
+
|
| 26 |
+
- `src/main.tsx` - punto de entrada, configuración de router y React Query
|
| 27 |
+
- `src/App.tsx` - rutas principales de la aplicación
|
| 28 |
+
- `src/store` - estado global con Zustand
|
| 29 |
+
- `src/api` - cliente y funciones de API centralizadas
|
| 30 |
+
- `src/hooks` - hooks reutilizables del frontend
|
| 31 |
+
- `src/features` - features organizadas por dominio
|
| 32 |
+
- `src/utils` - utilidades compartidas
|
| 33 |
+
- `src/types.ts` - tipos globales de la aplicación
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
## 3. Rutas principales
|
| 38 |
+
|
| 39 |
+
`src/App.tsx` define las rutas:
|
| 40 |
+
|
| 41 |
+
- `/` → `RoomSetup`
|
| 42 |
+
- `/visualizer` → `RoomVisualizer`
|
| 43 |
+
- `/settings` → `SettingsPage`
|
| 44 |
+
- `*` → redirección a `/`
|
| 45 |
+
|
| 46 |
+
---
|
| 47 |
+
|
| 48 |
+
## 4. Estado global (Zustand)
|
| 49 |
+
|
| 50 |
+
`src/store/useAppStore.ts` almacena:
|
| 51 |
+
|
| 52 |
+
- `previewImage` - URL de la imagen cargada
|
| 53 |
+
- `uploadMessage` - estado de mensaje de subida
|
| 54 |
+
- `openProductId` - producto seleccionado en `RoomVisualizer`
|
| 55 |
+
- `viewMode` - vista actual de productos en el visualizador (`grid` o `list`)
|
| 56 |
+
|
| 57 |
+
Funciones disponibles:
|
| 58 |
+
|
| 59 |
+
- `setPreviewImage`
|
| 60 |
+
- `setUploadMessage`
|
| 61 |
+
- `setOpenProductId`
|
| 62 |
+
- `setViewMode`
|
| 63 |
+
- `reset`
|
| 64 |
+
|
| 65 |
+
---
|
| 66 |
+
|
| 67 |
+
## 5. Cliente API centralizado
|
| 68 |
+
|
| 69 |
+
`src/api/client.ts` incluye:
|
| 70 |
+
|
| 71 |
+
- `DEV_API_BASE` y `API_BASE` para la URL del backend
|
| 72 |
+
- `getApiBase` / `buildApiUrl` para construir endpoints
|
| 73 |
+
- `fetchClientConfig` para obtener configuración de cliente
|
| 74 |
+
- `uploadRoomImage` para subir imágenes al servidor
|
| 75 |
+
- `startSession` para iniciar una sesión remota
|
| 76 |
+
|
| 77 |
+
Esta capa permite mantener la URL del backend en un solo lugar y soportar un token de desarrollo automático.
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
## 6. Hooks compartidos
|
| 82 |
+
|
| 83 |
+
### `src/hooks/useUploadImage.ts`
|
| 84 |
+
|
| 85 |
+
- Maneja la subida de imágenes al backend
|
| 86 |
+
- Controla `isUploading` y `uploadError`
|
| 87 |
+
- Envuelve `uploadRoomImage` del cliente API
|
| 88 |
+
|
| 89 |
+
---
|
| 90 |
+
|
| 91 |
+
## 7. Tipos globales
|
| 92 |
+
|
| 93 |
+
`src/types.ts` declara tipos comunes como:
|
| 94 |
+
|
| 95 |
+
- `ClientData`
|
| 96 |
+
- `RouteItem`
|
| 97 |
+
- `Product`
|
| 98 |
+
|
| 99 |
+
El tipo `Product` se usa en el visualizador para productos de catálogo.
|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
## 8. Feature: Room Setup
|
| 104 |
+
|
| 105 |
+
### Archivos
|
| 106 |
+
|
| 107 |
+
- `src/features/roomSetup/RoomSetup.tsx`
|
| 108 |
+
- `src/features/roomSetup/roomSetupHooks.ts`
|
| 109 |
+
- `src/features/roomSetup/RoomSetupComponents.tsx`
|
| 110 |
+
- `src/data/roomSetupData.ts`
|
| 111 |
+
|
| 112 |
+
### Comportamiento
|
| 113 |
+
|
| 114 |
+
`RoomSetup` es la página principal donde el usuario puede:
|
| 115 |
+
|
| 116 |
+
- subir una imagen arrastrando o seleccionando archivo
|
| 117 |
+
- ver una vista previa de la imagen
|
| 118 |
+
- iniciar la navegación hacia el visualizador
|
| 119 |
+
- ver habitaciones de demostración filtrables
|
| 120 |
+
|
| 121 |
+
### `useRoomSetup`
|
| 122 |
+
|
| 123 |
+
Este hook abstrae toda la lógica de:
|
| 124 |
+
|
| 125 |
+
- drag & drop
|
| 126 |
+
- selección de archivos
|
| 127 |
+
- subida de imagen
|
| 128 |
+
- gestión de estados de carga
|
| 129 |
+
- navegación con `useNavigate`
|
| 130 |
+
|
| 131 |
+
### Componentes auxiliares
|
| 132 |
+
|
| 133 |
+
- `FilterButton` - botón de filtrado por categoría
|
| 134 |
+
- `RoomCard` - tarjeta de habitación de demostración
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
|
| 138 |
+
## 9. Feature: Room Visualizer
|
| 139 |
+
|
| 140 |
+
### Archivos
|
| 141 |
+
|
| 142 |
+
- `src/features/roomVisualizer/RoomVisualizer.tsx`
|
| 143 |
+
- `src/features/roomVisualizer/roomVisualizerHooks.ts`
|
| 144 |
+
- `src/features/roomVisualizer/roomVisualizerData.ts`
|
| 145 |
+
- `src/features/roomVisualizer/ProductCards.tsx`
|
| 146 |
+
|
| 147 |
+
### Comportamiento
|
| 148 |
+
|
| 149 |
+
`RoomVisualizer` muestra:
|
| 150 |
+
|
| 151 |
+
- la imagen subida por el usuario
|
| 152 |
+
- controles de zoom y arrastre de la imagen
|
| 153 |
+
- lista / grid de productos para aplicar en la habitación
|
| 154 |
+
- selección de producto y detalles asociados
|
| 155 |
+
|
| 156 |
+
### `useRoomVisualizer`
|
| 157 |
+
|
| 158 |
+
Extrae la lógica de visualización de productos:
|
| 159 |
+
|
| 160 |
+
- modo de vista (`grid` / `list`)
|
| 161 |
+
- búsqueda y filtros básicos
|
| 162 |
+
- producto seleccionado
|
| 163 |
+
- manejo de selección de producto
|
| 164 |
+
- agrupación en chunks para renderizar grillas
|
| 165 |
+
|
| 166 |
+
### Datos de productos
|
| 167 |
+
|
| 168 |
+
- `roomVisualizerData.ts` contiene el catálogo de productos estáticos usados en la vista.
|
| 169 |
+
|
| 170 |
+
### Componentes
|
| 171 |
+
|
| 172 |
+
- `ProductGroupCard` - renderiza un grupo de 3 productos en grid
|
| 173 |
+
- `IndividualProductCard` - renderiza un producto en modo lista
|
| 174 |
+
|
| 175 |
+
---
|
| 176 |
+
|
| 177 |
+
## 10. Feature: Settings
|
| 178 |
+
|
| 179 |
+
### Archivo
|
| 180 |
+
|
| 181 |
+
- `src/features/settings/SettingsPage.tsx`
|
| 182 |
+
|
| 183 |
+
### Comportamiento
|
| 184 |
+
|
| 185 |
+
La página de configuración permite al usuario:
|
| 186 |
+
|
| 187 |
+
- definir la URL base de la API
|
| 188 |
+
- activar / desactivar la lista de rutas en el viewer
|
| 189 |
+
- definir token de desarrollo automático
|
| 190 |
+
|
| 191 |
+
### Soporte de almacenamiento
|
| 192 |
+
|
| 193 |
+
`src/utils/settings.ts` gestiona:
|
| 194 |
+
|
| 195 |
+
- carga de `localStorage`
|
| 196 |
+
- guardado de configuración
|
| 197 |
+
- valores por defecto
|
| 198 |
+
|
| 199 |
+
---
|
| 200 |
+
|
| 201 |
+
## 11. Componentes comunes
|
| 202 |
+
|
| 203 |
+
- `src/components/ui/LoadingScreen.tsx` - pantalla de carga reutilizable
|
| 204 |
+
|
| 205 |
+
---
|
| 206 |
+
|
| 207 |
+
## 12. Flujo principal de la app
|
| 208 |
+
|
| 209 |
+
1. Usuario entra en `/`
|
| 210 |
+
2. Sube una imagen en `RoomSetup`
|
| 211 |
+
3. Se crea una vista previa y se almacena en Zustand
|
| 212 |
+
4. Se navega a `/visualizer`
|
| 213 |
+
5. `RoomVisualizer` usa la imagen y muestra productos
|
| 214 |
+
6. El usuario filtra, busca y selecciona productos
|
| 215 |
+
7. `SettingsPage` permite ajustar la API y opciones de la app
|
| 216 |
+
|
| 217 |
+
---
|
| 218 |
+
|
| 219 |
+
## 13. Cómo ejecutar el frontend
|
| 220 |
+
|
| 221 |
+
Desde `frontend/`:
|
| 222 |
+
|
| 223 |
+
```bash
|
| 224 |
+
npm install
|
| 225 |
+
npm run dev
|
| 226 |
+
```
|
| 227 |
+
|
| 228 |
+
Construcción de producción:
|
| 229 |
+
|
| 230 |
+
```bash
|
| 231 |
+
npm run build
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
---
|
| 235 |
+
|
| 236 |
+
## 14. Posibles mejoras futuras
|
| 237 |
+
|
| 238 |
+
- mover `roomVisualizerProducts` a una API real o servicio de datos
|
| 239 |
+
- extraer más componentes presentacionales del visualizador
|
| 240 |
+
- usar React Query para cargar configuración del cliente en lugar de gestión manual
|
| 241 |
+
- añadir tests unitarios para hooks y componentes
|
| 242 |
+
- normalizar rutas y nombres de features
|
| 243 |
+
|
| 244 |
+
---
|
| 245 |
+
|
| 246 |
+
## 15. Observaciones adicionales
|
| 247 |
+
|
| 248 |
+
- La base API se controla desde `VITE_API_BASE_URL` o `DEV_API_BASE` en desarrollo.
|
| 249 |
+
- La aplicación usa `queryClient` de React Query, aunque hoy solo se usa para potenciales fetches futuros.
|
| 250 |
+
- El estado global es ligero y se usa principalmente para compartir imagen previa, modo de vista y selección de producto.
|
frontend/README.md
CHANGED
|
@@ -1,73 +1,73 @@
|
|
| 1 |
-
# React + TypeScript + Vite
|
| 2 |
-
|
| 3 |
-
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
-
|
| 5 |
-
Currently, two official plugins are available:
|
| 6 |
-
|
| 7 |
-
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
| 8 |
-
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
| 9 |
-
|
| 10 |
-
## React Compiler
|
| 11 |
-
|
| 12 |
-
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| 13 |
-
|
| 14 |
-
## Expanding the ESLint configuration
|
| 15 |
-
|
| 16 |
-
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
| 17 |
-
|
| 18 |
-
```js
|
| 19 |
-
export default defineConfig([
|
| 20 |
-
globalIgnores(['dist']),
|
| 21 |
-
{
|
| 22 |
-
files: ['**/*.{ts,tsx}'],
|
| 23 |
-
extends: [
|
| 24 |
-
// Other configs...
|
| 25 |
-
|
| 26 |
-
// Remove tseslint.configs.recommended and replace with this
|
| 27 |
-
tseslint.configs.recommendedTypeChecked,
|
| 28 |
-
// Alternatively, use this for stricter rules
|
| 29 |
-
tseslint.configs.strictTypeChecked,
|
| 30 |
-
// Optionally, add this for stylistic rules
|
| 31 |
-
tseslint.configs.stylisticTypeChecked,
|
| 32 |
-
|
| 33 |
-
// Other configs...
|
| 34 |
-
],
|
| 35 |
-
languageOptions: {
|
| 36 |
-
parserOptions: {
|
| 37 |
-
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 38 |
-
tsconfigRootDir: import.meta.dirname,
|
| 39 |
-
},
|
| 40 |
-
// other options...
|
| 41 |
-
},
|
| 42 |
-
},
|
| 43 |
-
])
|
| 44 |
-
```
|
| 45 |
-
|
| 46 |
-
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
| 47 |
-
|
| 48 |
-
```js
|
| 49 |
-
// eslint.config.js
|
| 50 |
-
import reactX from 'eslint-plugin-react-x'
|
| 51 |
-
import reactDom from 'eslint-plugin-react-dom'
|
| 52 |
-
|
| 53 |
-
export default defineConfig([
|
| 54 |
-
globalIgnores(['dist']),
|
| 55 |
-
{
|
| 56 |
-
files: ['**/*.{ts,tsx}'],
|
| 57 |
-
extends: [
|
| 58 |
-
// Other configs...
|
| 59 |
-
// Enable lint rules for React
|
| 60 |
-
reactX.configs['recommended-typescript'],
|
| 61 |
-
// Enable lint rules for React DOM
|
| 62 |
-
reactDom.configs.recommended,
|
| 63 |
-
],
|
| 64 |
-
languageOptions: {
|
| 65 |
-
parserOptions: {
|
| 66 |
-
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 67 |
-
tsconfigRootDir: import.meta.dirname,
|
| 68 |
-
},
|
| 69 |
-
// other options...
|
| 70 |
-
},
|
| 71 |
-
},
|
| 72 |
-
])
|
| 73 |
-
```
|
|
|
|
| 1 |
+
# React + TypeScript + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| 13 |
+
|
| 14 |
+
## Expanding the ESLint configuration
|
| 15 |
+
|
| 16 |
+
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
| 17 |
+
|
| 18 |
+
```js
|
| 19 |
+
export default defineConfig([
|
| 20 |
+
globalIgnores(['dist']),
|
| 21 |
+
{
|
| 22 |
+
files: ['**/*.{ts,tsx}'],
|
| 23 |
+
extends: [
|
| 24 |
+
// Other configs...
|
| 25 |
+
|
| 26 |
+
// Remove tseslint.configs.recommended and replace with this
|
| 27 |
+
tseslint.configs.recommendedTypeChecked,
|
| 28 |
+
// Alternatively, use this for stricter rules
|
| 29 |
+
tseslint.configs.strictTypeChecked,
|
| 30 |
+
// Optionally, add this for stylistic rules
|
| 31 |
+
tseslint.configs.stylisticTypeChecked,
|
| 32 |
+
|
| 33 |
+
// Other configs...
|
| 34 |
+
],
|
| 35 |
+
languageOptions: {
|
| 36 |
+
parserOptions: {
|
| 37 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 38 |
+
tsconfigRootDir: import.meta.dirname,
|
| 39 |
+
},
|
| 40 |
+
// other options...
|
| 41 |
+
},
|
| 42 |
+
},
|
| 43 |
+
])
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
| 47 |
+
|
| 48 |
+
```js
|
| 49 |
+
// eslint.config.js
|
| 50 |
+
import reactX from 'eslint-plugin-react-x'
|
| 51 |
+
import reactDom from 'eslint-plugin-react-dom'
|
| 52 |
+
|
| 53 |
+
export default defineConfig([
|
| 54 |
+
globalIgnores(['dist']),
|
| 55 |
+
{
|
| 56 |
+
files: ['**/*.{ts,tsx}'],
|
| 57 |
+
extends: [
|
| 58 |
+
// Other configs...
|
| 59 |
+
// Enable lint rules for React
|
| 60 |
+
reactX.configs['recommended-typescript'],
|
| 61 |
+
// Enable lint rules for React DOM
|
| 62 |
+
reactDom.configs.recommended,
|
| 63 |
+
],
|
| 64 |
+
languageOptions: {
|
| 65 |
+
parserOptions: {
|
| 66 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 67 |
+
tsconfigRootDir: import.meta.dirname,
|
| 68 |
+
},
|
| 69 |
+
// other options...
|
| 70 |
+
},
|
| 71 |
+
},
|
| 72 |
+
])
|
| 73 |
+
```
|
frontend/eslint.config.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
| 1 |
-
import js from '@eslint/js'
|
| 2 |
-
import globals from 'globals'
|
| 3 |
-
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
-
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
-
import tseslint from 'typescript-eslint'
|
| 6 |
-
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 7 |
-
|
| 8 |
-
export default defineConfig([
|
| 9 |
-
globalIgnores(['dist']),
|
| 10 |
-
{
|
| 11 |
-
files: ['**/*.{ts,tsx}'],
|
| 12 |
-
extends: [
|
| 13 |
-
js.configs.recommended,
|
| 14 |
-
tseslint.configs.recommended,
|
| 15 |
-
reactHooks.configs.flat.recommended,
|
| 16 |
-
reactRefresh.configs.vite,
|
| 17 |
-
],
|
| 18 |
-
languageOptions: {
|
| 19 |
-
ecmaVersion: 2020,
|
| 20 |
-
globals: globals.browser,
|
| 21 |
-
},
|
| 22 |
-
},
|
| 23 |
-
])
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import tseslint from 'typescript-eslint'
|
| 6 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 7 |
+
|
| 8 |
+
export default defineConfig([
|
| 9 |
+
globalIgnores(['dist']),
|
| 10 |
+
{
|
| 11 |
+
files: ['**/*.{ts,tsx}'],
|
| 12 |
+
extends: [
|
| 13 |
+
js.configs.recommended,
|
| 14 |
+
tseslint.configs.recommended,
|
| 15 |
+
reactHooks.configs.flat.recommended,
|
| 16 |
+
reactRefresh.configs.vite,
|
| 17 |
+
],
|
| 18 |
+
languageOptions: {
|
| 19 |
+
ecmaVersion: 2020,
|
| 20 |
+
globals: globals.browser,
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
])
|
frontend/index.html
CHANGED
|
@@ -1,35 +1,28 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html lang="es">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8" />
|
| 5 |
-
|
| 6 |
-
<!-- Viewport: viewport-fit=cover extiende hasta los bordes en dispositivos con notch -->
|
| 7 |
-
<meta
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
/>
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
<
|
| 14 |
-
<meta name="
|
| 15 |
-
|
| 16 |
-
<
|
| 17 |
-
<
|
| 18 |
-
|
| 19 |
-
<
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
/>
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
<
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
<title>Hyper Reality Visualizer</title>
|
| 30 |
-
</head>
|
| 31 |
-
<body>
|
| 32 |
-
<div id="root"></div>
|
| 33 |
-
<script type="module" src="/src/main.tsx"></script>
|
| 34 |
-
</body>
|
| 35 |
-
</html>
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="es">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
|
| 6 |
+
<!-- Viewport: viewport-fit=cover extiende hasta los bordes en dispositivos con notch -->
|
| 7 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
| 8 |
+
|
| 9 |
+
<!-- PWA -->
|
| 10 |
+
<link rel="manifest" href="/manifest.json" />
|
| 11 |
+
<meta name="theme-color" content="#0047AB" />
|
| 12 |
+
|
| 13 |
+
<!-- iOS PWA: fullscreen al instalarse desde Safari -->
|
| 14 |
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
| 15 |
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
| 16 |
+
<meta name="apple-mobile-web-app-title" content="Hyper Reality" />
|
| 17 |
+
<link rel="apple-touch-icon" href="/favicon.svg" />
|
| 18 |
+
|
| 19 |
+
<!-- Icono -->
|
| 20 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 21 |
+
|
| 22 |
+
<title>Hyper Reality Visualizer</title>
|
| 23 |
+
</head>
|
| 24 |
+
<body>
|
| 25 |
+
<div id="root"></div>
|
| 26 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 27 |
+
</body>
|
| 28 |
+
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/package.json
CHANGED
|
@@ -1,47 +1,47 @@
|
|
| 1 |
-
{
|
| 2 |
-
"name": "frontend",
|
| 3 |
-
"private": true,
|
| 4 |
-
"version": "0.1.0",
|
| 5 |
-
"type": "module",
|
| 6 |
-
"scripts": {
|
| 7 |
-
"dev": "vite",
|
| 8 |
-
"build": "tsc -b && vite build",
|
| 9 |
-
"build:app": "npm run build",
|
| 10 |
-
"lint": "eslint .",
|
| 11 |
-
"preview": "vite preview",
|
| 12 |
-
"generate:version": "node ./scripts/generate-version.js",
|
| 13 |
-
"predev": "npm run generate:version",
|
| 14 |
-
"prebuild": "npm run generate:version",
|
| 15 |
-
"version:patch": "npm version patch --no-git-tag-version",
|
| 16 |
-
"version:minor": "npm version minor --no-git-tag-version",
|
| 17 |
-
"version:major": "npm version major --no-git-tag-version"
|
| 18 |
-
},
|
| 19 |
-
"dependencies": {
|
| 20 |
-
"@tanstack/react-query": "^5.99.2",
|
| 21 |
-
"@tanstack/react-query-devtools": "^5.99.2",
|
| 22 |
-
"lucide-react": "^1.8.0",
|
| 23 |
-
"react": "^19.2.5",
|
| 24 |
-
"react-dom": "^19.2.5",
|
| 25 |
-
"react-router-dom": "^7.14.2",
|
| 26 |
-
"react-share": "^5.3.0",
|
| 27 |
-
"sweetalert2": "^11.26.24",
|
| 28 |
-
"zustand": "^5.0.12"
|
| 29 |
-
},
|
| 30 |
-
"devDependencies": {
|
| 31 |
-
"@eslint/js": "^9.39.4",
|
| 32 |
-
"@types/node": "^24.12.2",
|
| 33 |
-
"@types/react": "^19.2.14",
|
| 34 |
-
"@types/react-dom": "^19.2.3",
|
| 35 |
-
"@vitejs/plugin-react": "^6.0.1",
|
| 36 |
-
"autoprefixer": "^10.4.19",
|
| 37 |
-
"eslint": "^9.39.4",
|
| 38 |
-
"eslint-plugin-react-hooks": "^7.1.1",
|
| 39 |
-
"eslint-plugin-react-refresh": "^0.5.2",
|
| 40 |
-
"globals": "^17.5.0",
|
| 41 |
-
"postcss": "^8.5.10",
|
| 42 |
-
"tailwindcss": "^3.4.5",
|
| 43 |
-
"typescript": "~6.0.2",
|
| 44 |
-
"typescript-eslint": "^8.58.2",
|
| 45 |
-
"vite": "^8.0.9"
|
| 46 |
-
}
|
| 47 |
-
}
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.1.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc -b && vite build",
|
| 9 |
+
"build:app": "npm run build",
|
| 10 |
+
"lint": "eslint .",
|
| 11 |
+
"preview": "vite preview",
|
| 12 |
+
"generate:version": "node ./scripts/generate-version.js",
|
| 13 |
+
"predev": "npm run generate:version",
|
| 14 |
+
"prebuild": "npm run generate:version",
|
| 15 |
+
"version:patch": "npm version patch --no-git-tag-version",
|
| 16 |
+
"version:minor": "npm version minor --no-git-tag-version",
|
| 17 |
+
"version:major": "npm version major --no-git-tag-version"
|
| 18 |
+
},
|
| 19 |
+
"dependencies": {
|
| 20 |
+
"@tanstack/react-query": "^5.99.2",
|
| 21 |
+
"@tanstack/react-query-devtools": "^5.99.2",
|
| 22 |
+
"lucide-react": "^1.8.0",
|
| 23 |
+
"react": "^19.2.5",
|
| 24 |
+
"react-dom": "^19.2.5",
|
| 25 |
+
"react-router-dom": "^7.14.2",
|
| 26 |
+
"react-share": "^5.3.0",
|
| 27 |
+
"sweetalert2": "^11.26.24",
|
| 28 |
+
"zustand": "^5.0.12"
|
| 29 |
+
},
|
| 30 |
+
"devDependencies": {
|
| 31 |
+
"@eslint/js": "^9.39.4",
|
| 32 |
+
"@types/node": "^24.12.2",
|
| 33 |
+
"@types/react": "^19.2.14",
|
| 34 |
+
"@types/react-dom": "^19.2.3",
|
| 35 |
+
"@vitejs/plugin-react": "^6.0.1",
|
| 36 |
+
"autoprefixer": "^10.4.19",
|
| 37 |
+
"eslint": "^9.39.4",
|
| 38 |
+
"eslint-plugin-react-hooks": "^7.1.1",
|
| 39 |
+
"eslint-plugin-react-refresh": "^0.5.2",
|
| 40 |
+
"globals": "^17.5.0",
|
| 41 |
+
"postcss": "^8.5.10",
|
| 42 |
+
"tailwindcss": "^3.4.5",
|
| 43 |
+
"typescript": "~6.0.2",
|
| 44 |
+
"typescript-eslint": "^8.58.2",
|
| 45 |
+
"vite": "^8.0.9"
|
| 46 |
+
}
|
| 47 |
+
}
|
frontend/postcss.config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
export default {
|
| 2 |
-
plugins: {
|
| 3 |
-
tailwindcss: {},
|
| 4 |
-
autoprefixer: {},
|
| 5 |
-
},
|
| 6 |
-
};
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
};
|
frontend/public/icons.svg
CHANGED
|
|
|
|
frontend/rewrite_css.py
CHANGED
|
@@ -1,509 +1,509 @@
|
|
| 1 |
-
from pathlib import Path
|
| 2 |
-
|
| 3 |
-
app_css = '''
|
| 4 |
-
:root {
|
| 5 |
-
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
| 6 |
-
color: #111827;
|
| 7 |
-
background: #f8fafc;
|
| 8 |
-
line-height: 1.5;
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
*, *::before, *::after {
|
| 12 |
-
box-sizing: border-box;
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
html, body, #root {
|
| 16 |
-
min-height: 100%;
|
| 17 |
-
}
|
| 18 |
-
|
| 19 |
-
body {
|
| 20 |
-
margin: 0;
|
| 21 |
-
background: #f8fafc;
|
| 22 |
-
color: #111827;
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
button, input, select, textarea {
|
| 26 |
-
font: inherit;
|
| 27 |
-
}
|
| 28 |
-
|
| 29 |
-
button {
|
| 30 |
-
cursor: pointer;
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
.app-shell {
|
| 34 |
-
max-width: 1200px;
|
| 35 |
-
margin: 0 auto;
|
| 36 |
-
padding: 24px;
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
.topbar {
|
| 40 |
-
display: flex;
|
| 41 |
-
flex-wrap: wrap;
|
| 42 |
-
justify-content: space-between;
|
| 43 |
-
align-items: center;
|
| 44 |
-
gap: 16px;
|
| 45 |
-
padding: 22px 24px;
|
| 46 |
-
background: #ffffff;
|
| 47 |
-
border: 1px solid #e5e7eb;
|
| 48 |
-
border-radius: 18px;
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
.topbar h1 {
|
| 52 |
-
margin: 0;
|
| 53 |
-
font-size: clamp(1.9rem, 2.5vw, 2.6rem);
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
.topbar p {
|
| 57 |
-
margin: 6px 0 0;
|
| 58 |
-
color: #475569;
|
| 59 |
-
}
|
| 60 |
-
|
| 61 |
-
.topbar-nav {
|
| 62 |
-
display: flex;
|
| 63 |
-
flex-wrap: wrap;
|
| 64 |
-
gap: 10px;
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
.topbar-nav a {
|
| 68 |
-
display: inline-flex;
|
| 69 |
-
align-items: center;
|
| 70 |
-
justify-content: center;
|
| 71 |
-
padding: 10px 14px;
|
| 72 |
-
border-radius: 14px;
|
| 73 |
-
background: #f8fafc;
|
| 74 |
-
color: #334155;
|
| 75 |
-
text-decoration: none;
|
| 76 |
-
border: 1px solid transparent;
|
| 77 |
-
transition: background 0.2s ease, border-color 0.2s ease;
|
| 78 |
-
}
|
| 79 |
-
|
| 80 |
-
.topbar-nav a:hover {
|
| 81 |
-
background: #ffffff;
|
| 82 |
-
border-color: #d1d5db;
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
.content-viewer {
|
| 86 |
-
margin-top: 24px;
|
| 87 |
-
}
|
| 88 |
-
|
| 89 |
-
.route-index,
|
| 90 |
-
.panel,
|
| 91 |
-
.viewer-panel,
|
| 92 |
-
.bridge-panel,
|
| 93 |
-
.room-setup-dropzone,
|
| 94 |
-
.room-setup-card,
|
| 95 |
-
.room-setup-preview {
|
| 96 |
-
background: #ffffff;
|
| 97 |
-
border: 1px solid #e5e7eb;
|
| 98 |
-
border-radius: 20px;
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
.route-index,
|
| 102 |
-
.panel,
|
| 103 |
-
.viewer-panel,
|
| 104 |
-
.bridge-panel,
|
| 105 |
-
.room-setup-card {
|
| 106 |
-
padding: 22px;
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
.route-index h2,
|
| 110 |
-
.panel h2,
|
| 111 |
-
.viewer-panel h2,
|
| 112 |
-
.room-setup-copy h1,
|
| 113 |
-
.room-setup-bottom h2 {
|
| 114 |
-
margin: 0 0 16px;
|
| 115 |
-
}
|
| 116 |
-
|
| 117 |
-
.route-index p,
|
| 118 |
-
.room-setup-features li,
|
| 119 |
-
.room-setup-drop-content p,
|
| 120 |
-
.room-setup-drop-content small,
|
| 121 |
-
.room-setup-card h3,
|
| 122 |
-
.topbar p,
|
| 123 |
-
.error-box,
|
| 124 |
-
.empty-state {
|
| 125 |
-
color: #475569;
|
| 126 |
-
}
|
| 127 |
-
|
| 128 |
-
.route-index ul,
|
| 129 |
-
.room-setup-features,
|
| 130 |
-
.room-setup-filters,
|
| 131 |
-
.room-setup-grid {
|
| 132 |
-
margin: 0;
|
| 133 |
-
padding: 0;
|
| 134 |
-
list-style: none;
|
| 135 |
-
}
|
| 136 |
-
|
| 137 |
-
.route-index ul {
|
| 138 |
-
display: grid;
|
| 139 |
-
gap: 12px;
|
| 140 |
-
}
|
| 141 |
-
|
| 142 |
-
.form-row {
|
| 143 |
-
display: grid;
|
| 144 |
-
gap: 14px;
|
| 145 |
-
margin-bottom: 20px;
|
| 146 |
-
}
|
| 147 |
-
|
| 148 |
-
label {
|
| 149 |
-
font-size: 0.95rem;
|
| 150 |
-
color: #334155;
|
| 151 |
-
}
|
| 152 |
-
|
| 153 |
-
input,
|
| 154 |
-
select,
|
| 155 |
-
textarea {
|
| 156 |
-
width: 100%;
|
| 157 |
-
padding: 14px 16px;
|
| 158 |
-
border-radius: 14px;
|
| 159 |
-
border: 1px solid #d1d5db;
|
| 160 |
-
background: #f8fafc;
|
| 161 |
-
color: #111827;
|
| 162 |
-
}
|
| 163 |
-
|
| 164 |
-
input:focus,
|
| 165 |
-
select:focus,
|
| 166 |
-
textarea:focus {
|
| 167 |
-
outline: 2px solid #cbd5e1;
|
| 168 |
-
outline-offset: 2px;
|
| 169 |
-
}
|
| 170 |
-
|
| 171 |
-
.button,
|
| 172 |
-
.button-primary,
|
| 173 |
-
.button-secondary {
|
| 174 |
-
display: inline-flex;
|
| 175 |
-
align-items: center;
|
| 176 |
-
justify-content: center;
|
| 177 |
-
gap: 10px;
|
| 178 |
-
border-radius: 16px;
|
| 179 |
-
padding: 14px 18px;
|
| 180 |
-
font-weight: 700;
|
| 181 |
-
border: 1px solid transparent;
|
| 182 |
-
transition: all 0.2s ease;
|
| 183 |
-
}
|
| 184 |
-
|
| 185 |
-
.button {
|
| 186 |
-
background: #111827;
|
| 187 |
-
color: #ffffff;
|
| 188 |
-
}
|
| 189 |
-
|
| 190 |
-
.button:hover {
|
| 191 |
-
opacity: 0.95;
|
| 192 |
-
}
|
| 193 |
-
|
| 194 |
-
.button.secondary,
|
| 195 |
-
.button-secondary {
|
| 196 |
-
background: #f3f4f6;
|
| 197 |
-
color: #111827;
|
| 198 |
-
border-color: #d1d5db;
|
| 199 |
-
}
|
| 200 |
-
|
| 201 |
-
.error-box {
|
| 202 |
-
margin-top: 16px;
|
| 203 |
-
padding: 16px;
|
| 204 |
-
border-radius: 16px;
|
| 205 |
-
background: #fef2f2;
|
| 206 |
-
color: #991b1b;
|
| 207 |
-
border: 1px solid #fecaca;
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
-
.empty-state {
|
| 211 |
-
min-height: 220px;
|
| 212 |
-
display: grid;
|
| 213 |
-
place-content: center;
|
| 214 |
-
border: 1px dashed #d1d5db;
|
| 215 |
-
border-radius: 18px;
|
| 216 |
-
}
|
| 217 |
-
|
| 218 |
-
.room-setup {
|
| 219 |
-
background: #f8fafc;
|
| 220 |
-
}
|
| 221 |
-
|
| 222 |
-
.room-setup-inner {
|
| 223 |
-
max-width: 1200px;
|
| 224 |
-
margin: 0 auto;
|
| 225 |
-
padding: 32px 24px;
|
| 226 |
-
}
|
| 227 |
-
|
| 228 |
-
.room-setup-header {
|
| 229 |
-
display: flex;
|
| 230 |
-
justify-content: flex-end;
|
| 231 |
-
margin-bottom: 24px;
|
| 232 |
-
}
|
| 233 |
-
|
| 234 |
-
.room-setup-close {
|
| 235 |
-
border: none;
|
| 236 |
-
background: transparent;
|
| 237 |
-
color: #475569;
|
| 238 |
-
padding: 10px;
|
| 239 |
-
border-radius: 999px;
|
| 240 |
-
cursor: pointer;
|
| 241 |
-
}
|
| 242 |
-
|
| 243 |
-
.room-setup-close:hover {
|
| 244 |
-
background: #e5e7eb;
|
| 245 |
-
}
|
| 246 |
-
|
| 247 |
-
.room-setup-top {
|
| 248 |
-
display: grid;
|
| 249 |
-
gap: 24px;
|
| 250 |
-
grid-template-columns: 1.1fr 0.9fr;
|
| 251 |
-
margin-bottom: 40px;
|
| 252 |
-
}
|
| 253 |
-
|
| 254 |
-
.room-setup-copy h1 {
|
| 255 |
-
margin: 0 0 24px;
|
| 256 |
-
font-size: clamp(2rem, 2.5vw, 3rem);
|
| 257 |
-
color: #111827;
|
| 258 |
-
}
|
| 259 |
-
|
| 260 |
-
.room-setup-features {
|
| 261 |
-
display: grid;
|
| 262 |
-
gap: 16px;
|
| 263 |
-
}
|
| 264 |
-
|
| 265 |
-
.room-setup-features li {
|
| 266 |
-
display: flex;
|
| 267 |
-
align-items: center;
|
| 268 |
-
gap: 12px;
|
| 269 |
-
color: #475569;
|
| 270 |
-
}
|
| 271 |
-
|
| 272 |
-
.room-setup-actions {
|
| 273 |
-
display: grid;
|
| 274 |
-
gap: 16px;
|
| 275 |
-
}
|
| 276 |
-
|
| 277 |
-
.button-primary {
|
| 278 |
-
background: #111827;
|
| 279 |
-
color: #ffffff;
|
| 280 |
-
}
|
| 281 |
-
|
| 282 |
-
.button-primary:hover {
|
| 283 |
-
opacity: 0.95;
|
| 284 |
-
}
|
| 285 |
-
|
| 286 |
-
.button-secondary {
|
| 287 |
-
background: #ffffff;
|
| 288 |
-
color: #334155;
|
| 289 |
-
border-color: #d1d5db;
|
| 290 |
-
}
|
| 291 |
-
|
| 292 |
-
.button-secondary:hover {
|
| 293 |
-
background: #f3f4f6;
|
| 294 |
-
}
|
| 295 |
-
|
| 296 |
-
.button-icon-border {
|
| 297 |
-
display: inline-flex;
|
| 298 |
-
align-items: center;
|
| 299 |
-
justify-content: center;
|
| 300 |
-
width: 30px;
|
| 301 |
-
height: 30px;
|
| 302 |
-
border-radius: 10px;
|
| 303 |
-
border: 1px solid #d1d5db;
|
| 304 |
-
}
|
| 305 |
-
|
| 306 |
-
.room-setup-dropzone {
|
| 307 |
-
position: relative;
|
| 308 |
-
min-height: 420px;
|
| 309 |
-
display: flex;
|
| 310 |
-
align-items: center;
|
| 311 |
-
justify-content: center;
|
| 312 |
-
transition: all 0.2s ease;
|
| 313 |
-
padding: 24px;
|
| 314 |
-
border: 1px dashed #d1d5db;
|
| 315 |
-
border-radius: 28px;
|
| 316 |
-
}
|
| 317 |
-
|
| 318 |
-
.room-setup-dropzone.dragging {
|
| 319 |
-
background: #f3f4f6;
|
| 320 |
-
}
|
| 321 |
-
|
| 322 |
-
.room-setup-file-input {
|
| 323 |
-
position: absolute;
|
| 324 |
-
inset: 0;
|
| 325 |
-
width: 100%;
|
| 326 |
-
height: 100%;
|
| 327 |
-
opacity: 0;
|
| 328 |
-
cursor: pointer;
|
| 329 |
-
}
|
| 330 |
-
|
| 331 |
-
.room-setup-drop-content {
|
| 332 |
-
text-align: center;
|
| 333 |
-
}
|
| 334 |
-
|
| 335 |
-
.room-setup-drop-icon {
|
| 336 |
-
display: inline-flex;
|
| 337 |
-
align-items: center;
|
| 338 |
-
justify-content: center;
|
| 339 |
-
width: 72px;
|
| 340 |
-
height: 72px;
|
| 341 |
-
border-radius: 999px;
|
| 342 |
-
background: #e2e8f0;
|
| 343 |
-
color: #64748b;
|
| 344 |
-
margin-bottom: 18px;
|
| 345 |
-
}
|
| 346 |
-
|
| 347 |
-
.room-setup-drop-icon.active {
|
| 348 |
-
background: #dbeafe;
|
| 349 |
-
color: #1e293b;
|
| 350 |
-
}
|
| 351 |
-
|
| 352 |
-
.room-setup-drop-content h3 {
|
| 353 |
-
margin: 0 0 8px;
|
| 354 |
-
font-size: 1.2rem;
|
| 355 |
-
}
|
| 356 |
-
|
| 357 |
-
.room-setup-drop-content p,
|
| 358 |
-
.room-setup-drop-content small {
|
| 359 |
-
margin: 0;
|
| 360 |
-
color: #64748b;
|
| 361 |
-
}
|
| 362 |
-
|
| 363 |
-
.room-setup-preview {
|
| 364 |
-
position: relative;
|
| 365 |
-
width: 100%;
|
| 366 |
-
height: 100%;
|
| 367 |
-
}
|
| 368 |
-
|
| 369 |
-
.room-setup-preview img {
|
| 370 |
-
width: 100%;
|
| 371 |
-
height: 100%;
|
| 372 |
-
object-fit: cover;
|
| 373 |
-
}
|
| 374 |
-
|
| 375 |
-
.room-setup-preview-overlay {
|
| 376 |
-
position: absolute;
|
| 377 |
-
inset: 0;
|
| 378 |
-
background: rgba(255, 255, 255, 0.85);
|
| 379 |
-
display: flex;
|
| 380 |
-
align-items: center;
|
| 381 |
-
justify-content: center;
|
| 382 |
-
opacity: 0;
|
| 383 |
-
transition: opacity 0.2s ease;
|
| 384 |
-
}
|
| 385 |
-
|
| 386 |
-
.room-setup-preview:hover .room-setup-preview-overlay {
|
| 387 |
-
opacity: 1;
|
| 388 |
-
}
|
| 389 |
-
|
| 390 |
-
.button-delete {
|
| 391 |
-
background: #ffffff;
|
| 392 |
-
color: #111827;
|
| 393 |
-
padding: 12px 16px;
|
| 394 |
-
border-radius: 14px;
|
| 395 |
-
font-weight: 600;
|
| 396 |
-
border: 1px solid #d1d5db;
|
| 397 |
-
}
|
| 398 |
-
|
| 399 |
-
.room-setup-bottom h2 {
|
| 400 |
-
margin: 0 0 24px;
|
| 401 |
-
}
|
| 402 |
-
|
| 403 |
-
.room-setup-filters {
|
| 404 |
-
display: flex;
|
| 405 |
-
flex-wrap: wrap;
|
| 406 |
-
gap: 10px;
|
| 407 |
-
margin-bottom: 24px;
|
| 408 |
-
}
|
| 409 |
-
|
| 410 |
-
.room-setup-filter {
|
| 411 |
-
border: 1px solid #d1d5db;
|
| 412 |
-
background: #ffffff;
|
| 413 |
-
color: #334155;
|
| 414 |
-
padding: 10px 16px;
|
| 415 |
-
border-radius: 16px;
|
| 416 |
-
cursor: pointer;
|
| 417 |
-
transition: all 0.2s ease;
|
| 418 |
-
}
|
| 419 |
-
|
| 420 |
-
.room-setup-filter.active {
|
| 421 |
-
background: #f3f4f6;
|
| 422 |
-
}
|
| 423 |
-
|
| 424 |
-
.room-setup-grid {
|
| 425 |
-
display: grid;
|
| 426 |
-
grid-template-columns: repeat(1, minmax(0, 1fr));
|
| 427 |
-
gap: 20px;
|
| 428 |
-
}
|
| 429 |
-
|
| 430 |
-
.room-setup-card {
|
| 431 |
-
overflow: hidden;
|
| 432 |
-
border-radius: 24px;
|
| 433 |
-
background: #ffffff;
|
| 434 |
-
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
| 435 |
-
}
|
| 436 |
-
|
| 437 |
-
.room-setup-card-image {
|
| 438 |
-
position: relative;
|
| 439 |
-
aspect-ratio: 4 / 3;
|
| 440 |
-
overflow: hidden;
|
| 441 |
-
}
|
| 442 |
-
|
| 443 |
-
.room-setup-card-image img {
|
| 444 |
-
width: 100%;
|
| 445 |
-
height: 100%;
|
| 446 |
-
object-fit: cover;
|
| 447 |
-
transition: transform 0.4s ease;
|
| 448 |
-
}
|
| 449 |
-
|
| 450 |
-
.room-setup-card:hover .room-setup-card-image img {
|
| 451 |
-
transform: scale(1.04);
|
| 452 |
-
}
|
| 453 |
-
|
| 454 |
-
.room-setup-card h3 {
|
| 455 |
-
margin: 16px;
|
| 456 |
-
font-size: 1rem;
|
| 457 |
-
color: #334155;
|
| 458 |
-
}
|
| 459 |
-
|
| 460 |
-
.app-version-badge {
|
| 461 |
-
position: fixed;
|
| 462 |
-
right: 0;
|
| 463 |
-
bottom: 0;
|
| 464 |
-
padding: 10px 14px;
|
| 465 |
-
border-radius: 12px 0 0 0;
|
| 466 |
-
background: rgba(255, 255, 255, 0.95);
|
| 467 |
-
border: 1px solid #e5e7eb;
|
| 468 |
-
color: #475569;
|
| 469 |
-
font-size: 0.8rem;
|
| 470 |
-
z-index: 1000;
|
| 471 |
-
}
|
| 472 |
-
|
| 473 |
-
.app-version-badge span {
|
| 474 |
-
display: block;
|
| 475 |
-
font-weight: 700;
|
| 476 |
-
color: #111827;
|
| 477 |
-
}
|
| 478 |
-
|
| 479 |
-
.app-version-badge small {
|
| 480 |
-
display: block;
|
| 481 |
-
margin-top: 2px;
|
| 482 |
-
}
|
| 483 |
-
|
| 484 |
-
@media (max-width: 960px) {
|
| 485 |
-
.room-setup-top {
|
| 486 |
-
grid-template-columns: 1fr;
|
| 487 |
-
}
|
| 488 |
-
|
| 489 |
-
.room-setup-grid {
|
| 490 |
-
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 491 |
-
}
|
| 492 |
-
}
|
| 493 |
-
|
| 494 |
-
@media (max-width: 640px) {
|
| 495 |
-
.room-setup-inner {
|
| 496 |
-
padding: 20px 16px;
|
| 497 |
-
}
|
| 498 |
-
|
| 499 |
-
.room-setup-grid {
|
| 500 |
-
grid-template-columns: 1fr;
|
| 501 |
-
}
|
| 502 |
-
}
|
| 503 |
-
|
| 504 |
-
@media (max-width: 900px) {
|
| 505 |
-
.content {
|
| 506 |
-
grid-template-columns: 1fr;
|
| 507 |
-
}
|
| 508 |
-
}
|
| 509 |
'@; Set-Content -Path '.\src\App.css' -Value $content"
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
app_css = '''
|
| 4 |
+
:root {
|
| 5 |
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
| 6 |
+
color: #111827;
|
| 7 |
+
background: #f8fafc;
|
| 8 |
+
line-height: 1.5;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
*, *::before, *::after {
|
| 12 |
+
box-sizing: border-box;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
html, body, #root {
|
| 16 |
+
min-height: 100%;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
body {
|
| 20 |
+
margin: 0;
|
| 21 |
+
background: #f8fafc;
|
| 22 |
+
color: #111827;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
button, input, select, textarea {
|
| 26 |
+
font: inherit;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
button {
|
| 30 |
+
cursor: pointer;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.app-shell {
|
| 34 |
+
max-width: 1200px;
|
| 35 |
+
margin: 0 auto;
|
| 36 |
+
padding: 24px;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.topbar {
|
| 40 |
+
display: flex;
|
| 41 |
+
flex-wrap: wrap;
|
| 42 |
+
justify-content: space-between;
|
| 43 |
+
align-items: center;
|
| 44 |
+
gap: 16px;
|
| 45 |
+
padding: 22px 24px;
|
| 46 |
+
background: #ffffff;
|
| 47 |
+
border: 1px solid #e5e7eb;
|
| 48 |
+
border-radius: 18px;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.topbar h1 {
|
| 52 |
+
margin: 0;
|
| 53 |
+
font-size: clamp(1.9rem, 2.5vw, 2.6rem);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.topbar p {
|
| 57 |
+
margin: 6px 0 0;
|
| 58 |
+
color: #475569;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.topbar-nav {
|
| 62 |
+
display: flex;
|
| 63 |
+
flex-wrap: wrap;
|
| 64 |
+
gap: 10px;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.topbar-nav a {
|
| 68 |
+
display: inline-flex;
|
| 69 |
+
align-items: center;
|
| 70 |
+
justify-content: center;
|
| 71 |
+
padding: 10px 14px;
|
| 72 |
+
border-radius: 14px;
|
| 73 |
+
background: #f8fafc;
|
| 74 |
+
color: #334155;
|
| 75 |
+
text-decoration: none;
|
| 76 |
+
border: 1px solid transparent;
|
| 77 |
+
transition: background 0.2s ease, border-color 0.2s ease;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.topbar-nav a:hover {
|
| 81 |
+
background: #ffffff;
|
| 82 |
+
border-color: #d1d5db;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.content-viewer {
|
| 86 |
+
margin-top: 24px;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.route-index,
|
| 90 |
+
.panel,
|
| 91 |
+
.viewer-panel,
|
| 92 |
+
.bridge-panel,
|
| 93 |
+
.room-setup-dropzone,
|
| 94 |
+
.room-setup-card,
|
| 95 |
+
.room-setup-preview {
|
| 96 |
+
background: #ffffff;
|
| 97 |
+
border: 1px solid #e5e7eb;
|
| 98 |
+
border-radius: 20px;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.route-index,
|
| 102 |
+
.panel,
|
| 103 |
+
.viewer-panel,
|
| 104 |
+
.bridge-panel,
|
| 105 |
+
.room-setup-card {
|
| 106 |
+
padding: 22px;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.route-index h2,
|
| 110 |
+
.panel h2,
|
| 111 |
+
.viewer-panel h2,
|
| 112 |
+
.room-setup-copy h1,
|
| 113 |
+
.room-setup-bottom h2 {
|
| 114 |
+
margin: 0 0 16px;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.route-index p,
|
| 118 |
+
.room-setup-features li,
|
| 119 |
+
.room-setup-drop-content p,
|
| 120 |
+
.room-setup-drop-content small,
|
| 121 |
+
.room-setup-card h3,
|
| 122 |
+
.topbar p,
|
| 123 |
+
.error-box,
|
| 124 |
+
.empty-state {
|
| 125 |
+
color: #475569;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.route-index ul,
|
| 129 |
+
.room-setup-features,
|
| 130 |
+
.room-setup-filters,
|
| 131 |
+
.room-setup-grid {
|
| 132 |
+
margin: 0;
|
| 133 |
+
padding: 0;
|
| 134 |
+
list-style: none;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.route-index ul {
|
| 138 |
+
display: grid;
|
| 139 |
+
gap: 12px;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.form-row {
|
| 143 |
+
display: grid;
|
| 144 |
+
gap: 14px;
|
| 145 |
+
margin-bottom: 20px;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
label {
|
| 149 |
+
font-size: 0.95rem;
|
| 150 |
+
color: #334155;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
input,
|
| 154 |
+
select,
|
| 155 |
+
textarea {
|
| 156 |
+
width: 100%;
|
| 157 |
+
padding: 14px 16px;
|
| 158 |
+
border-radius: 14px;
|
| 159 |
+
border: 1px solid #d1d5db;
|
| 160 |
+
background: #f8fafc;
|
| 161 |
+
color: #111827;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
input:focus,
|
| 165 |
+
select:focus,
|
| 166 |
+
textarea:focus {
|
| 167 |
+
outline: 2px solid #cbd5e1;
|
| 168 |
+
outline-offset: 2px;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.button,
|
| 172 |
+
.button-primary,
|
| 173 |
+
.button-secondary {
|
| 174 |
+
display: inline-flex;
|
| 175 |
+
align-items: center;
|
| 176 |
+
justify-content: center;
|
| 177 |
+
gap: 10px;
|
| 178 |
+
border-radius: 16px;
|
| 179 |
+
padding: 14px 18px;
|
| 180 |
+
font-weight: 700;
|
| 181 |
+
border: 1px solid transparent;
|
| 182 |
+
transition: all 0.2s ease;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.button {
|
| 186 |
+
background: #111827;
|
| 187 |
+
color: #ffffff;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
.button:hover {
|
| 191 |
+
opacity: 0.95;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.button.secondary,
|
| 195 |
+
.button-secondary {
|
| 196 |
+
background: #f3f4f6;
|
| 197 |
+
color: #111827;
|
| 198 |
+
border-color: #d1d5db;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.error-box {
|
| 202 |
+
margin-top: 16px;
|
| 203 |
+
padding: 16px;
|
| 204 |
+
border-radius: 16px;
|
| 205 |
+
background: #fef2f2;
|
| 206 |
+
color: #991b1b;
|
| 207 |
+
border: 1px solid #fecaca;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.empty-state {
|
| 211 |
+
min-height: 220px;
|
| 212 |
+
display: grid;
|
| 213 |
+
place-content: center;
|
| 214 |
+
border: 1px dashed #d1d5db;
|
| 215 |
+
border-radius: 18px;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.room-setup {
|
| 219 |
+
background: #f8fafc;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.room-setup-inner {
|
| 223 |
+
max-width: 1200px;
|
| 224 |
+
margin: 0 auto;
|
| 225 |
+
padding: 32px 24px;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.room-setup-header {
|
| 229 |
+
display: flex;
|
| 230 |
+
justify-content: flex-end;
|
| 231 |
+
margin-bottom: 24px;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
.room-setup-close {
|
| 235 |
+
border: none;
|
| 236 |
+
background: transparent;
|
| 237 |
+
color: #475569;
|
| 238 |
+
padding: 10px;
|
| 239 |
+
border-radius: 999px;
|
| 240 |
+
cursor: pointer;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
.room-setup-close:hover {
|
| 244 |
+
background: #e5e7eb;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
.room-setup-top {
|
| 248 |
+
display: grid;
|
| 249 |
+
gap: 24px;
|
| 250 |
+
grid-template-columns: 1.1fr 0.9fr;
|
| 251 |
+
margin-bottom: 40px;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.room-setup-copy h1 {
|
| 255 |
+
margin: 0 0 24px;
|
| 256 |
+
font-size: clamp(2rem, 2.5vw, 3rem);
|
| 257 |
+
color: #111827;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.room-setup-features {
|
| 261 |
+
display: grid;
|
| 262 |
+
gap: 16px;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
.room-setup-features li {
|
| 266 |
+
display: flex;
|
| 267 |
+
align-items: center;
|
| 268 |
+
gap: 12px;
|
| 269 |
+
color: #475569;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.room-setup-actions {
|
| 273 |
+
display: grid;
|
| 274 |
+
gap: 16px;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.button-primary {
|
| 278 |
+
background: #111827;
|
| 279 |
+
color: #ffffff;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
.button-primary:hover {
|
| 283 |
+
opacity: 0.95;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.button-secondary {
|
| 287 |
+
background: #ffffff;
|
| 288 |
+
color: #334155;
|
| 289 |
+
border-color: #d1d5db;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.button-secondary:hover {
|
| 293 |
+
background: #f3f4f6;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.button-icon-border {
|
| 297 |
+
display: inline-flex;
|
| 298 |
+
align-items: center;
|
| 299 |
+
justify-content: center;
|
| 300 |
+
width: 30px;
|
| 301 |
+
height: 30px;
|
| 302 |
+
border-radius: 10px;
|
| 303 |
+
border: 1px solid #d1d5db;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.room-setup-dropzone {
|
| 307 |
+
position: relative;
|
| 308 |
+
min-height: 420px;
|
| 309 |
+
display: flex;
|
| 310 |
+
align-items: center;
|
| 311 |
+
justify-content: center;
|
| 312 |
+
transition: all 0.2s ease;
|
| 313 |
+
padding: 24px;
|
| 314 |
+
border: 1px dashed #d1d5db;
|
| 315 |
+
border-radius: 28px;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.room-setup-dropzone.dragging {
|
| 319 |
+
background: #f3f4f6;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
.room-setup-file-input {
|
| 323 |
+
position: absolute;
|
| 324 |
+
inset: 0;
|
| 325 |
+
width: 100%;
|
| 326 |
+
height: 100%;
|
| 327 |
+
opacity: 0;
|
| 328 |
+
cursor: pointer;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.room-setup-drop-content {
|
| 332 |
+
text-align: center;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.room-setup-drop-icon {
|
| 336 |
+
display: inline-flex;
|
| 337 |
+
align-items: center;
|
| 338 |
+
justify-content: center;
|
| 339 |
+
width: 72px;
|
| 340 |
+
height: 72px;
|
| 341 |
+
border-radius: 999px;
|
| 342 |
+
background: #e2e8f0;
|
| 343 |
+
color: #64748b;
|
| 344 |
+
margin-bottom: 18px;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
.room-setup-drop-icon.active {
|
| 348 |
+
background: #dbeafe;
|
| 349 |
+
color: #1e293b;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.room-setup-drop-content h3 {
|
| 353 |
+
margin: 0 0 8px;
|
| 354 |
+
font-size: 1.2rem;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
.room-setup-drop-content p,
|
| 358 |
+
.room-setup-drop-content small {
|
| 359 |
+
margin: 0;
|
| 360 |
+
color: #64748b;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
.room-setup-preview {
|
| 364 |
+
position: relative;
|
| 365 |
+
width: 100%;
|
| 366 |
+
height: 100%;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
.room-setup-preview img {
|
| 370 |
+
width: 100%;
|
| 371 |
+
height: 100%;
|
| 372 |
+
object-fit: cover;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
.room-setup-preview-overlay {
|
| 376 |
+
position: absolute;
|
| 377 |
+
inset: 0;
|
| 378 |
+
background: rgba(255, 255, 255, 0.85);
|
| 379 |
+
display: flex;
|
| 380 |
+
align-items: center;
|
| 381 |
+
justify-content: center;
|
| 382 |
+
opacity: 0;
|
| 383 |
+
transition: opacity 0.2s ease;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
.room-setup-preview:hover .room-setup-preview-overlay {
|
| 387 |
+
opacity: 1;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
.button-delete {
|
| 391 |
+
background: #ffffff;
|
| 392 |
+
color: #111827;
|
| 393 |
+
padding: 12px 16px;
|
| 394 |
+
border-radius: 14px;
|
| 395 |
+
font-weight: 600;
|
| 396 |
+
border: 1px solid #d1d5db;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
.room-setup-bottom h2 {
|
| 400 |
+
margin: 0 0 24px;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
.room-setup-filters {
|
| 404 |
+
display: flex;
|
| 405 |
+
flex-wrap: wrap;
|
| 406 |
+
gap: 10px;
|
| 407 |
+
margin-bottom: 24px;
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
.room-setup-filter {
|
| 411 |
+
border: 1px solid #d1d5db;
|
| 412 |
+
background: #ffffff;
|
| 413 |
+
color: #334155;
|
| 414 |
+
padding: 10px 16px;
|
| 415 |
+
border-radius: 16px;
|
| 416 |
+
cursor: pointer;
|
| 417 |
+
transition: all 0.2s ease;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
.room-setup-filter.active {
|
| 421 |
+
background: #f3f4f6;
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
.room-setup-grid {
|
| 425 |
+
display: grid;
|
| 426 |
+
grid-template-columns: repeat(1, minmax(0, 1fr));
|
| 427 |
+
gap: 20px;
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
.room-setup-card {
|
| 431 |
+
overflow: hidden;
|
| 432 |
+
border-radius: 24px;
|
| 433 |
+
background: #ffffff;
|
| 434 |
+
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
.room-setup-card-image {
|
| 438 |
+
position: relative;
|
| 439 |
+
aspect-ratio: 4 / 3;
|
| 440 |
+
overflow: hidden;
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
.room-setup-card-image img {
|
| 444 |
+
width: 100%;
|
| 445 |
+
height: 100%;
|
| 446 |
+
object-fit: cover;
|
| 447 |
+
transition: transform 0.4s ease;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
.room-setup-card:hover .room-setup-card-image img {
|
| 451 |
+
transform: scale(1.04);
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
.room-setup-card h3 {
|
| 455 |
+
margin: 16px;
|
| 456 |
+
font-size: 1rem;
|
| 457 |
+
color: #334155;
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
.app-version-badge {
|
| 461 |
+
position: fixed;
|
| 462 |
+
right: 0;
|
| 463 |
+
bottom: 0;
|
| 464 |
+
padding: 10px 14px;
|
| 465 |
+
border-radius: 12px 0 0 0;
|
| 466 |
+
background: rgba(255, 255, 255, 0.95);
|
| 467 |
+
border: 1px solid #e5e7eb;
|
| 468 |
+
color: #475569;
|
| 469 |
+
font-size: 0.8rem;
|
| 470 |
+
z-index: 1000;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
.app-version-badge span {
|
| 474 |
+
display: block;
|
| 475 |
+
font-weight: 700;
|
| 476 |
+
color: #111827;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
.app-version-badge small {
|
| 480 |
+
display: block;
|
| 481 |
+
margin-top: 2px;
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
@media (max-width: 960px) {
|
| 485 |
+
.room-setup-top {
|
| 486 |
+
grid-template-columns: 1fr;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
.room-setup-grid {
|
| 490 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 491 |
+
}
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
@media (max-width: 640px) {
|
| 495 |
+
.room-setup-inner {
|
| 496 |
+
padding: 20px 16px;
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
.room-setup-grid {
|
| 500 |
+
grid-template-columns: 1fr;
|
| 501 |
+
}
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
@media (max-width: 900px) {
|
| 505 |
+
.content {
|
| 506 |
+
grid-template-columns: 1fr;
|
| 507 |
+
}
|
| 508 |
+
}
|
| 509 |
'@; Set-Content -Path '.\src\App.css' -Value $content"
|
frontend/scripts/generate-version.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
| 1 |
-
import { readFileSync, writeFileSync } from "node:fs";
|
| 2 |
-
import { dirname, join } from "node:path";
|
| 3 |
-
import { fileURLToPath } from "node:url";
|
| 4 |
-
|
| 5 |
-
const root = dirname(fileURLToPath(import.meta.url));
|
| 6 |
-
const frontendRoot = join(root, "..");
|
| 7 |
-
const pkg = JSON.parse(
|
| 8 |
-
readFileSync(join(frontendRoot, "package.json"), "utf8"),
|
| 9 |
-
);
|
| 10 |
-
const version = pkg.version || "0.0.0";
|
| 11 |
-
const timestamp = new Date()
|
| 12 |
-
.toISOString()
|
| 13 |
-
.replace(/[-:]/g, "")
|
| 14 |
-
.replace(/\.\d+Z$/, "");
|
| 15 |
-
const fullVersion = `${version}-dev.${timestamp}`;
|
| 16 |
-
const content = `export const appVersion = ${JSON.stringify(fullVersion)};\n`;
|
| 17 |
-
|
| 18 |
-
writeFileSync(join(frontendRoot, "src", "version.ts"), content, "utf8");
|
| 19 |
-
console.log(`Generated version: ${fullVersion}`);
|
|
|
|
| 1 |
+
import { readFileSync, writeFileSync } from "node:fs";
|
| 2 |
+
import { dirname, join } from "node:path";
|
| 3 |
+
import { fileURLToPath } from "node:url";
|
| 4 |
+
|
| 5 |
+
const root = dirname(fileURLToPath(import.meta.url));
|
| 6 |
+
const frontendRoot = join(root, "..");
|
| 7 |
+
const pkg = JSON.parse(
|
| 8 |
+
readFileSync(join(frontendRoot, "package.json"), "utf8"),
|
| 9 |
+
);
|
| 10 |
+
const version = pkg.version || "0.0.0";
|
| 11 |
+
const timestamp = new Date()
|
| 12 |
+
.toISOString()
|
| 13 |
+
.replace(/[-:]/g, "")
|
| 14 |
+
.replace(/\.\d+Z$/, "");
|
| 15 |
+
const fullVersion = `${version}-dev.${timestamp}`;
|
| 16 |
+
const content = `export const appVersion = ${JSON.stringify(fullVersion)};\n`;
|
| 17 |
+
|
| 18 |
+
writeFileSync(join(frontendRoot, "src", "version.ts"), content, "utf8");
|
| 19 |
+
console.log(`Generated version: ${fullVersion}`);
|
frontend/src/App.css
CHANGED
|
@@ -1,542 +1,542 @@
|
|
| 1 |
-
:root {
|
| 2 |
-
font-family:
|
| 3 |
-
Inter,
|
| 4 |
-
ui-sans-serif,
|
| 5 |
-
system-ui,
|
| 6 |
-
-apple-system,
|
| 7 |
-
BlinkMacSystemFont,
|
| 8 |
-
"Segoe UI",
|
| 9 |
-
sans-serif;
|
| 10 |
-
color: var(--brand-black);
|
| 11 |
-
background: var(--brand-light);
|
| 12 |
-
line-height: 1.5;
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
* {
|
| 16 |
-
box-sizing: border-box;
|
| 17 |
-
}
|
| 18 |
-
|
| 19 |
-
html,
|
| 20 |
-
body,
|
| 21 |
-
#root {
|
| 22 |
-
min-height: 100%;
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
body {
|
| 26 |
-
margin: 0;
|
| 27 |
-
background: var(--brand-light);
|
| 28 |
-
color: var(--brand-black);
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
button,
|
| 32 |
-
input,
|
| 33 |
-
select,
|
| 34 |
-
textarea {
|
| 35 |
-
font: inherit;
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
button {
|
| 39 |
-
cursor: pointer;
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
.app-shell {
|
| 43 |
-
max-width: 1200px;
|
| 44 |
-
margin: 0 auto;
|
| 45 |
-
padding: 24px;
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
.topbar {
|
| 49 |
-
display: flex;
|
| 50 |
-
flex-wrap: wrap;
|
| 51 |
-
justify-content: space-between;
|
| 52 |
-
align-items: center;
|
| 53 |
-
gap: 16px;
|
| 54 |
-
padding: 22px 24px;
|
| 55 |
-
background: var(--brand-surface);
|
| 56 |
-
border: 1px solid var(--brand-border);
|
| 57 |
-
border-radius: 18px;
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
.topbar h1 {
|
| 61 |
-
margin: 0;
|
| 62 |
-
font-size: clamp(1.9rem, 2.5vw, 2.6rem);
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
-
.topbar p {
|
| 66 |
-
margin: 6px 0 0;
|
| 67 |
-
color: var(--brand-gray);
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
-
.topbar-nav {
|
| 71 |
-
display: flex;
|
| 72 |
-
flex-wrap: wrap;
|
| 73 |
-
gap: 10px;
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
.topbar-nav a {
|
| 77 |
-
display: inline-flex;
|
| 78 |
-
align-items: center;
|
| 79 |
-
justify-content: center;
|
| 80 |
-
padding: 10px 14px;
|
| 81 |
-
border-radius: 14px;
|
| 82 |
-
background: var(--brand-surface);
|
| 83 |
-
color: var(--brand-black);
|
| 84 |
-
text-decoration: none;
|
| 85 |
-
border: 1px solid transparent;
|
| 86 |
-
transition:
|
| 87 |
-
background 0.2s ease,
|
| 88 |
-
border-color 0.2s ease;
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
.topbar-nav a:hover {
|
| 92 |
-
background: #ffffff;
|
| 93 |
-
border-color: #d1d5db;
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
.content {
|
| 97 |
-
display: grid;
|
| 98 |
-
gap: 24px;
|
| 99 |
-
grid-template-columns: 1.2fr 1.8fr;
|
| 100 |
-
margin-top: 24px;
|
| 101 |
-
}
|
| 102 |
-
|
| 103 |
-
.route-index,
|
| 104 |
-
.panel,
|
| 105 |
-
.viewer-panel,
|
| 106 |
-
.bridge-panel,
|
| 107 |
-
.room-setup-dropzone,
|
| 108 |
-
.room-setup-card,
|
| 109 |
-
.room-setup-preview {
|
| 110 |
-
background: var(--brand-surface);
|
| 111 |
-
border: 1px solid var(--brand-border);
|
| 112 |
-
border-radius: 20px;
|
| 113 |
-
}
|
| 114 |
-
|
| 115 |
-
.route-index,
|
| 116 |
-
.panel,
|
| 117 |
-
.viewer-panel,
|
| 118 |
-
.bridge-panel,
|
| 119 |
-
.room-setup-card {
|
| 120 |
-
padding: 22px;
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
.route-index h2,
|
| 124 |
-
.panel h2,
|
| 125 |
-
.viewer-panel h2,
|
| 126 |
-
.room-setup-copy h1,
|
| 127 |
-
.room-setup-bottom h2 {
|
| 128 |
-
margin: 0 0 16px;
|
| 129 |
-
}
|
| 130 |
-
|
| 131 |
-
.route-index p,
|
| 132 |
-
.room-setup-features li,
|
| 133 |
-
.room-setup-drop-content p,
|
| 134 |
-
.room-setup-drop-content small,
|
| 135 |
-
.room-setup-card h3,
|
| 136 |
-
.topbar p,
|
| 137 |
-
.error-box,
|
| 138 |
-
.empty-state {
|
| 139 |
-
color: #475569;
|
| 140 |
-
}
|
| 141 |
-
|
| 142 |
-
.route-index ul,
|
| 143 |
-
.room-setup-features,
|
| 144 |
-
.room-setup-filters,
|
| 145 |
-
.room-setup-grid {
|
| 146 |
-
margin: 0;
|
| 147 |
-
padding: 0;
|
| 148 |
-
list-style: none;
|
| 149 |
-
}
|
| 150 |
-
|
| 151 |
-
.route-index ul {
|
| 152 |
-
display: grid;
|
| 153 |
-
gap: 12px;
|
| 154 |
-
}
|
| 155 |
-
|
| 156 |
-
.form-row {
|
| 157 |
-
display: grid;
|
| 158 |
-
gap: 14px;
|
| 159 |
-
margin-bottom: 20px;
|
| 160 |
-
}
|
| 161 |
-
|
| 162 |
-
label {
|
| 163 |
-
font-size: 0.95rem;
|
| 164 |
-
color: var(--brand-black);
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
input,
|
| 168 |
-
select,
|
| 169 |
-
textarea {
|
| 170 |
-
width: 100%;
|
| 171 |
-
padding: 14px 16px;
|
| 172 |
-
border-radius: 14px;
|
| 173 |
-
border: 1px solid #d1d5db;
|
| 174 |
-
background: #ffffff;
|
| 175 |
-
color: #111827;
|
| 176 |
-
}
|
| 177 |
-
|
| 178 |
-
input:focus,
|
| 179 |
-
select:focus,
|
| 180 |
-
textarea:focus {
|
| 181 |
-
outline: 2px solid var(--brand-blue);
|
| 182 |
-
outline-offset: 2px;
|
| 183 |
-
}
|
| 184 |
-
|
| 185 |
-
.button,
|
| 186 |
-
.button-primary,
|
| 187 |
-
.button-secondary {
|
| 188 |
-
display: inline-flex;
|
| 189 |
-
align-items: center;
|
| 190 |
-
justify-content: center;
|
| 191 |
-
gap: 10px;
|
| 192 |
-
border-radius: 16px;
|
| 193 |
-
padding: 14px 18px;
|
| 194 |
-
font-weight: 700;
|
| 195 |
-
border: 1px solid transparent;
|
| 196 |
-
transition: all 0.2s ease;
|
| 197 |
-
}
|
| 198 |
-
|
| 199 |
-
.button {
|
| 200 |
-
background: var(--brand-black);
|
| 201 |
-
color: var(--brand-surface);
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
-
.button:hover {
|
| 205 |
-
opacity: 0.95;
|
| 206 |
-
}
|
| 207 |
-
|
| 208 |
-
.button.secondary,
|
| 209 |
-
.button-secondary {
|
| 210 |
-
background: var(--brand-surface);
|
| 211 |
-
color: var(--brand-black);
|
| 212 |
-
border-color: var(--brand-border);
|
| 213 |
-
}
|
| 214 |
-
|
| 215 |
-
.error-box {
|
| 216 |
-
margin-top: 16px;
|
| 217 |
-
padding: 16px;
|
| 218 |
-
border-radius: 16px;
|
| 219 |
-
background: #fef2f2;
|
| 220 |
-
color: #991b1b;
|
| 221 |
-
border: 1px solid #fecaca;
|
| 222 |
-
}
|
| 223 |
-
|
| 224 |
-
.empty-state {
|
| 225 |
-
min-height: 220px;
|
| 226 |
-
display: grid;
|
| 227 |
-
place-content: center;
|
| 228 |
-
border: 1px dashed #d1d5db;
|
| 229 |
-
border-radius: 18px;
|
| 230 |
-
}
|
| 231 |
-
|
| 232 |
-
.room-setup {
|
| 233 |
-
background: var(--brand-light);
|
| 234 |
-
}
|
| 235 |
-
|
| 236 |
-
.room-setup-inner {
|
| 237 |
-
max-width: 1200px;
|
| 238 |
-
margin: 0 auto;
|
| 239 |
-
padding: 32px 24px;
|
| 240 |
-
}
|
| 241 |
-
|
| 242 |
-
.room-setup-header {
|
| 243 |
-
display: flex;
|
| 244 |
-
justify-content: flex-end;
|
| 245 |
-
margin-bottom: 24px;
|
| 246 |
-
}
|
| 247 |
-
|
| 248 |
-
.room-setup-close {
|
| 249 |
-
border: none;
|
| 250 |
-
background: transparent;
|
| 251 |
-
color: var(--brand-black);
|
| 252 |
-
padding: 10px;
|
| 253 |
-
border-radius: 999px;
|
| 254 |
-
cursor: pointer;
|
| 255 |
-
}
|
| 256 |
-
|
| 257 |
-
.room-setup-close:hover {
|
| 258 |
-
background: var(--brand-border);
|
| 259 |
-
}
|
| 260 |
-
|
| 261 |
-
.room-setup-top {
|
| 262 |
-
display: grid;
|
| 263 |
-
gap: 24px;
|
| 264 |
-
grid-template-columns: 1.1fr 0.9fr;
|
| 265 |
-
margin-bottom: 40px;
|
| 266 |
-
}
|
| 267 |
-
|
| 268 |
-
.room-setup-copy h1 {
|
| 269 |
-
margin: 0 0 24px;
|
| 270 |
-
font-size: clamp(2rem, 2.5vw, 3rem);
|
| 271 |
-
color: #111827;
|
| 272 |
-
}
|
| 273 |
-
|
| 274 |
-
.room-setup-features {
|
| 275 |
-
display: grid;
|
| 276 |
-
gap: 16px;
|
| 277 |
-
}
|
| 278 |
-
|
| 279 |
-
.room-setup-features li {
|
| 280 |
-
display: flex;
|
| 281 |
-
align-items: center;
|
| 282 |
-
gap: 12px;
|
| 283 |
-
color: #475569;
|
| 284 |
-
}
|
| 285 |
-
|
| 286 |
-
.room-setup-actions {
|
| 287 |
-
display: grid;
|
| 288 |
-
gap: 16px;
|
| 289 |
-
}
|
| 290 |
-
|
| 291 |
-
.button-primary {
|
| 292 |
-
background: #111827;
|
| 293 |
-
color: #ffffff;
|
| 294 |
-
}
|
| 295 |
-
|
| 296 |
-
.button-primary:hover {
|
| 297 |
-
opacity: 0.95;
|
| 298 |
-
}
|
| 299 |
-
|
| 300 |
-
.button-secondary {
|
| 301 |
-
background: #ffffff;
|
| 302 |
-
color: #334155;
|
| 303 |
-
border-color: #d1d5db;
|
| 304 |
-
}
|
| 305 |
-
|
| 306 |
-
.button-secondary:hover {
|
| 307 |
-
background: #f3f4f6;
|
| 308 |
-
}
|
| 309 |
-
|
| 310 |
-
.button-icon-border {
|
| 311 |
-
display: inline-flex;
|
| 312 |
-
align-items: center;
|
| 313 |
-
justify-content: center;
|
| 314 |
-
width: 30px;
|
| 315 |
-
height: 30px;
|
| 316 |
-
border-radius: 10px;
|
| 317 |
-
border: 1px solid #d1d5db;
|
| 318 |
-
}
|
| 319 |
-
|
| 320 |
-
.room-setup-dropzone {
|
| 321 |
-
position: relative;
|
| 322 |
-
min-height: 420px;
|
| 323 |
-
display: flex;
|
| 324 |
-
align-items: center;
|
| 325 |
-
justify-content: center;
|
| 326 |
-
transition: all 0.2s ease;
|
| 327 |
-
padding: 24px;
|
| 328 |
-
border: 1px dashed #d1d5db;
|
| 329 |
-
border-radius: 28px;
|
| 330 |
-
}
|
| 331 |
-
|
| 332 |
-
.room-setup-dropzone.dragging {
|
| 333 |
-
background: #f3f4f6;
|
| 334 |
-
}
|
| 335 |
-
|
| 336 |
-
.room-setup-file-input {
|
| 337 |
-
position: absolute;
|
| 338 |
-
inset: 0;
|
| 339 |
-
width: 100%;
|
| 340 |
-
height: 100%;
|
| 341 |
-
opacity: 0;
|
| 342 |
-
cursor: pointer;
|
| 343 |
-
}
|
| 344 |
-
|
| 345 |
-
.room-setup-drop-content {
|
| 346 |
-
text-align: center;
|
| 347 |
-
}
|
| 348 |
-
|
| 349 |
-
.room-setup-drop-icon {
|
| 350 |
-
display: inline-flex;
|
| 351 |
-
align-items: center;
|
| 352 |
-
justify-content: center;
|
| 353 |
-
width: 72px;
|
| 354 |
-
height: 72px;
|
| 355 |
-
border-radius: 999px;
|
| 356 |
-
background: #e2e8f0;
|
| 357 |
-
color: #64748b;
|
| 358 |
-
margin-bottom: 18px;
|
| 359 |
-
}
|
| 360 |
-
|
| 361 |
-
.room-setup-drop-icon.active {
|
| 362 |
-
background: #dbeafe;
|
| 363 |
-
color: #1e293b;
|
| 364 |
-
}
|
| 365 |
-
|
| 366 |
-
.room-setup-drop-content h3 {
|
| 367 |
-
margin: 0 0 8px;
|
| 368 |
-
font-size: 1.2rem;
|
| 369 |
-
}
|
| 370 |
-
|
| 371 |
-
.room-setup-drop-content p,
|
| 372 |
-
.room-setup-drop-content small {
|
| 373 |
-
margin: 0;
|
| 374 |
-
color: #64748b;
|
| 375 |
-
}
|
| 376 |
-
|
| 377 |
-
.room-setup-preview {
|
| 378 |
-
position: relative;
|
| 379 |
-
width: 100%;
|
| 380 |
-
height: 100%;
|
| 381 |
-
}
|
| 382 |
-
|
| 383 |
-
.room-setup-preview img {
|
| 384 |
-
width: 100%;
|
| 385 |
-
height: 100%;
|
| 386 |
-
object-fit: cover;
|
| 387 |
-
}
|
| 388 |
-
|
| 389 |
-
.room-setup-preview-overlay {
|
| 390 |
-
position: absolute;
|
| 391 |
-
inset: 0;
|
| 392 |
-
background: rgba(255, 255, 255, 0.85);
|
| 393 |
-
display: flex;
|
| 394 |
-
align-items: center;
|
| 395 |
-
justify-content: center;
|
| 396 |
-
opacity: 0;
|
| 397 |
-
transition: opacity 0.2s ease;
|
| 398 |
-
}
|
| 399 |
-
|
| 400 |
-
.room-setup-preview:hover .room-setup-preview-overlay {
|
| 401 |
-
opacity: 1;
|
| 402 |
-
}
|
| 403 |
-
|
| 404 |
-
.button-delete {
|
| 405 |
-
background: #ffffff;
|
| 406 |
-
color: #111827;
|
| 407 |
-
padding: 12px 16px;
|
| 408 |
-
border-radius: 14px;
|
| 409 |
-
font-weight: 600;
|
| 410 |
-
border: 1px solid #d1d5db;
|
| 411 |
-
}
|
| 412 |
-
|
| 413 |
-
.room-setup-bottom h2 {
|
| 414 |
-
margin: 0 0 24px;
|
| 415 |
-
}
|
| 416 |
-
|
| 417 |
-
.room-setup-filters {
|
| 418 |
-
display: flex;
|
| 419 |
-
flex-wrap: wrap;
|
| 420 |
-
gap: 10px;
|
| 421 |
-
margin-bottom: 24px;
|
| 422 |
-
}
|
| 423 |
-
|
| 424 |
-
.room-setup-filter {
|
| 425 |
-
border: 1px solid #d1d5db;
|
| 426 |
-
background: #ffffff;
|
| 427 |
-
color: #334155;
|
| 428 |
-
padding: 10px 16px;
|
| 429 |
-
border-radius: 16px;
|
| 430 |
-
cursor: pointer;
|
| 431 |
-
transition: all 0.2s ease;
|
| 432 |
-
}
|
| 433 |
-
|
| 434 |
-
.room-setup-filter.active {
|
| 435 |
-
background: #f3f4f6;
|
| 436 |
-
}
|
| 437 |
-
|
| 438 |
-
.room-setup-grid {
|
| 439 |
-
display: grid;
|
| 440 |
-
grid-template-columns: repeat(1, minmax(0, 1fr));
|
| 441 |
-
gap: 20px;
|
| 442 |
-
}
|
| 443 |
-
|
| 444 |
-
.room-setup-card {
|
| 445 |
-
overflow: hidden;
|
| 446 |
-
border-radius: 24px;
|
| 447 |
-
background: #ffffff;
|
| 448 |
-
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
| 449 |
-
}
|
| 450 |
-
|
| 451 |
-
.room-setup-card-image {
|
| 452 |
-
position: relative;
|
| 453 |
-
aspect-ratio: 4 / 3;
|
| 454 |
-
overflow: hidden;
|
| 455 |
-
}
|
| 456 |
-
|
| 457 |
-
.room-setup-card-image img {
|
| 458 |
-
width: 100%;
|
| 459 |
-
height: 100%;
|
| 460 |
-
object-fit: cover;
|
| 461 |
-
transition: transform 0.4s ease;
|
| 462 |
-
}
|
| 463 |
-
|
| 464 |
-
.room-setup-card:hover .room-setup-card-image img {
|
| 465 |
-
transform: scale(1.04);
|
| 466 |
-
}
|
| 467 |
-
|
| 468 |
-
.room-setup-card h3 {
|
| 469 |
-
margin: 16px;
|
| 470 |
-
font-size: 1rem;
|
| 471 |
-
color: #334155;
|
| 472 |
-
}
|
| 473 |
-
|
| 474 |
-
@media (max-width: 960px) {
|
| 475 |
-
.room-setup-top {
|
| 476 |
-
grid-template-columns: 1fr;
|
| 477 |
-
}
|
| 478 |
-
|
| 479 |
-
.room-setup-grid {
|
| 480 |
-
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 481 |
-
}
|
| 482 |
-
}
|
| 483 |
-
|
| 484 |
-
@media (max-width: 640px) {
|
| 485 |
-
.room-setup-inner {
|
| 486 |
-
padding: 20px 16px;
|
| 487 |
-
}
|
| 488 |
-
|
| 489 |
-
.room-setup-grid {
|
| 490 |
-
grid-template-columns: 1fr;
|
| 491 |
-
}
|
| 492 |
-
}
|
| 493 |
-
|
| 494 |
-
.app-version-badge {
|
| 495 |
-
position: fixed;
|
| 496 |
-
right: 0;
|
| 497 |
-
bottom: 0;
|
| 498 |
-
padding: 6px 10px;
|
| 499 |
-
border-radius: 12px 0 0 0;
|
| 500 |
-
background: rgba(255, 255, 255, 0.95);
|
| 501 |
-
border: 1px solid #e5e7eb;
|
| 502 |
-
color: #475569;
|
| 503 |
-
font-size: 0.78rem;
|
| 504 |
-
text-align: right;
|
| 505 |
-
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
|
| 506 |
-
z-index: 1000;
|
| 507 |
-
}
|
| 508 |
-
|
| 509 |
-
.app-version-badge:hover {
|
| 510 |
-
opacity: 1;
|
| 511 |
-
}
|
| 512 |
-
|
| 513 |
-
.app-version-badge span {
|
| 514 |
-
display: block;
|
| 515 |
-
font-weight: 700;
|
| 516 |
-
color: #111827;
|
| 517 |
-
}
|
| 518 |
-
|
| 519 |
-
.app-version-badge small {
|
| 520 |
-
display: block;
|
| 521 |
-
margin-top: 1px;
|
| 522 |
-
color: #6b7280;
|
| 523 |
-
}
|
| 524 |
-
|
| 525 |
-
@media (max-width: 640px) {
|
| 526 |
-
.app-version-badge {
|
| 527 |
-
right: 6px;
|
| 528 |
-
bottom: 6px;
|
| 529 |
-
padding: 5px 8px;
|
| 530 |
-
font-size: 0.72rem;
|
| 531 |
-
}
|
| 532 |
-
|
| 533 |
-
.app-version-badge small {
|
| 534 |
-
margin-top: 0.5px;
|
| 535 |
-
}
|
| 536 |
-
}
|
| 537 |
-
|
| 538 |
-
@media (max-width: 900px) {
|
| 539 |
-
.content {
|
| 540 |
-
grid-template-columns: 1fr;
|
| 541 |
-
}
|
| 542 |
-
}
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
font-family:
|
| 3 |
+
Inter,
|
| 4 |
+
ui-sans-serif,
|
| 5 |
+
system-ui,
|
| 6 |
+
-apple-system,
|
| 7 |
+
BlinkMacSystemFont,
|
| 8 |
+
"Segoe UI",
|
| 9 |
+
sans-serif;
|
| 10 |
+
color: var(--brand-black);
|
| 11 |
+
background: var(--brand-light);
|
| 12 |
+
line-height: 1.5;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
* {
|
| 16 |
+
box-sizing: border-box;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
html,
|
| 20 |
+
body,
|
| 21 |
+
#root {
|
| 22 |
+
min-height: 100%;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
body {
|
| 26 |
+
margin: 0;
|
| 27 |
+
background: var(--brand-light);
|
| 28 |
+
color: var(--brand-black);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
button,
|
| 32 |
+
input,
|
| 33 |
+
select,
|
| 34 |
+
textarea {
|
| 35 |
+
font: inherit;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
button {
|
| 39 |
+
cursor: pointer;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.app-shell {
|
| 43 |
+
max-width: 1200px;
|
| 44 |
+
margin: 0 auto;
|
| 45 |
+
padding: 24px;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.topbar {
|
| 49 |
+
display: flex;
|
| 50 |
+
flex-wrap: wrap;
|
| 51 |
+
justify-content: space-between;
|
| 52 |
+
align-items: center;
|
| 53 |
+
gap: 16px;
|
| 54 |
+
padding: 22px 24px;
|
| 55 |
+
background: var(--brand-surface);
|
| 56 |
+
border: 1px solid var(--brand-border);
|
| 57 |
+
border-radius: 18px;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.topbar h1 {
|
| 61 |
+
margin: 0;
|
| 62 |
+
font-size: clamp(1.9rem, 2.5vw, 2.6rem);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.topbar p {
|
| 66 |
+
margin: 6px 0 0;
|
| 67 |
+
color: var(--brand-gray);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.topbar-nav {
|
| 71 |
+
display: flex;
|
| 72 |
+
flex-wrap: wrap;
|
| 73 |
+
gap: 10px;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.topbar-nav a {
|
| 77 |
+
display: inline-flex;
|
| 78 |
+
align-items: center;
|
| 79 |
+
justify-content: center;
|
| 80 |
+
padding: 10px 14px;
|
| 81 |
+
border-radius: 14px;
|
| 82 |
+
background: var(--brand-surface);
|
| 83 |
+
color: var(--brand-black);
|
| 84 |
+
text-decoration: none;
|
| 85 |
+
border: 1px solid transparent;
|
| 86 |
+
transition:
|
| 87 |
+
background 0.2s ease,
|
| 88 |
+
border-color 0.2s ease;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.topbar-nav a:hover {
|
| 92 |
+
background: #ffffff;
|
| 93 |
+
border-color: #d1d5db;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.content {
|
| 97 |
+
display: grid;
|
| 98 |
+
gap: 24px;
|
| 99 |
+
grid-template-columns: 1.2fr 1.8fr;
|
| 100 |
+
margin-top: 24px;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.route-index,
|
| 104 |
+
.panel,
|
| 105 |
+
.viewer-panel,
|
| 106 |
+
.bridge-panel,
|
| 107 |
+
.room-setup-dropzone,
|
| 108 |
+
.room-setup-card,
|
| 109 |
+
.room-setup-preview {
|
| 110 |
+
background: var(--brand-surface);
|
| 111 |
+
border: 1px solid var(--brand-border);
|
| 112 |
+
border-radius: 20px;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.route-index,
|
| 116 |
+
.panel,
|
| 117 |
+
.viewer-panel,
|
| 118 |
+
.bridge-panel,
|
| 119 |
+
.room-setup-card {
|
| 120 |
+
padding: 22px;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.route-index h2,
|
| 124 |
+
.panel h2,
|
| 125 |
+
.viewer-panel h2,
|
| 126 |
+
.room-setup-copy h1,
|
| 127 |
+
.room-setup-bottom h2 {
|
| 128 |
+
margin: 0 0 16px;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.route-index p,
|
| 132 |
+
.room-setup-features li,
|
| 133 |
+
.room-setup-drop-content p,
|
| 134 |
+
.room-setup-drop-content small,
|
| 135 |
+
.room-setup-card h3,
|
| 136 |
+
.topbar p,
|
| 137 |
+
.error-box,
|
| 138 |
+
.empty-state {
|
| 139 |
+
color: #475569;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.route-index ul,
|
| 143 |
+
.room-setup-features,
|
| 144 |
+
.room-setup-filters,
|
| 145 |
+
.room-setup-grid {
|
| 146 |
+
margin: 0;
|
| 147 |
+
padding: 0;
|
| 148 |
+
list-style: none;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.route-index ul {
|
| 152 |
+
display: grid;
|
| 153 |
+
gap: 12px;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.form-row {
|
| 157 |
+
display: grid;
|
| 158 |
+
gap: 14px;
|
| 159 |
+
margin-bottom: 20px;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
label {
|
| 163 |
+
font-size: 0.95rem;
|
| 164 |
+
color: var(--brand-black);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
input,
|
| 168 |
+
select,
|
| 169 |
+
textarea {
|
| 170 |
+
width: 100%;
|
| 171 |
+
padding: 14px 16px;
|
| 172 |
+
border-radius: 14px;
|
| 173 |
+
border: 1px solid #d1d5db;
|
| 174 |
+
background: #ffffff;
|
| 175 |
+
color: #111827;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
input:focus,
|
| 179 |
+
select:focus,
|
| 180 |
+
textarea:focus {
|
| 181 |
+
outline: 2px solid var(--brand-blue);
|
| 182 |
+
outline-offset: 2px;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.button,
|
| 186 |
+
.button-primary,
|
| 187 |
+
.button-secondary {
|
| 188 |
+
display: inline-flex;
|
| 189 |
+
align-items: center;
|
| 190 |
+
justify-content: center;
|
| 191 |
+
gap: 10px;
|
| 192 |
+
border-radius: 16px;
|
| 193 |
+
padding: 14px 18px;
|
| 194 |
+
font-weight: 700;
|
| 195 |
+
border: 1px solid transparent;
|
| 196 |
+
transition: all 0.2s ease;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.button {
|
| 200 |
+
background: var(--brand-black);
|
| 201 |
+
color: var(--brand-surface);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.button:hover {
|
| 205 |
+
opacity: 0.95;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.button.secondary,
|
| 209 |
+
.button-secondary {
|
| 210 |
+
background: var(--brand-surface);
|
| 211 |
+
color: var(--brand-black);
|
| 212 |
+
border-color: var(--brand-border);
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.error-box {
|
| 216 |
+
margin-top: 16px;
|
| 217 |
+
padding: 16px;
|
| 218 |
+
border-radius: 16px;
|
| 219 |
+
background: #fef2f2;
|
| 220 |
+
color: #991b1b;
|
| 221 |
+
border: 1px solid #fecaca;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.empty-state {
|
| 225 |
+
min-height: 220px;
|
| 226 |
+
display: grid;
|
| 227 |
+
place-content: center;
|
| 228 |
+
border: 1px dashed #d1d5db;
|
| 229 |
+
border-radius: 18px;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.room-setup {
|
| 233 |
+
background: var(--brand-light);
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.room-setup-inner {
|
| 237 |
+
max-width: 1200px;
|
| 238 |
+
margin: 0 auto;
|
| 239 |
+
padding: 32px 24px;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
.room-setup-header {
|
| 243 |
+
display: flex;
|
| 244 |
+
justify-content: flex-end;
|
| 245 |
+
margin-bottom: 24px;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
.room-setup-close {
|
| 249 |
+
border: none;
|
| 250 |
+
background: transparent;
|
| 251 |
+
color: var(--brand-black);
|
| 252 |
+
padding: 10px;
|
| 253 |
+
border-radius: 999px;
|
| 254 |
+
cursor: pointer;
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
.room-setup-close:hover {
|
| 258 |
+
background: var(--brand-border);
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
.room-setup-top {
|
| 262 |
+
display: grid;
|
| 263 |
+
gap: 24px;
|
| 264 |
+
grid-template-columns: 1.1fr 0.9fr;
|
| 265 |
+
margin-bottom: 40px;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
.room-setup-copy h1 {
|
| 269 |
+
margin: 0 0 24px;
|
| 270 |
+
font-size: clamp(2rem, 2.5vw, 3rem);
|
| 271 |
+
color: #111827;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.room-setup-features {
|
| 275 |
+
display: grid;
|
| 276 |
+
gap: 16px;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.room-setup-features li {
|
| 280 |
+
display: flex;
|
| 281 |
+
align-items: center;
|
| 282 |
+
gap: 12px;
|
| 283 |
+
color: #475569;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.room-setup-actions {
|
| 287 |
+
display: grid;
|
| 288 |
+
gap: 16px;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
.button-primary {
|
| 292 |
+
background: #111827;
|
| 293 |
+
color: #ffffff;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.button-primary:hover {
|
| 297 |
+
opacity: 0.95;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
.button-secondary {
|
| 301 |
+
background: #ffffff;
|
| 302 |
+
color: #334155;
|
| 303 |
+
border-color: #d1d5db;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.button-secondary:hover {
|
| 307 |
+
background: #f3f4f6;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
.button-icon-border {
|
| 311 |
+
display: inline-flex;
|
| 312 |
+
align-items: center;
|
| 313 |
+
justify-content: center;
|
| 314 |
+
width: 30px;
|
| 315 |
+
height: 30px;
|
| 316 |
+
border-radius: 10px;
|
| 317 |
+
border: 1px solid #d1d5db;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.room-setup-dropzone {
|
| 321 |
+
position: relative;
|
| 322 |
+
min-height: 420px;
|
| 323 |
+
display: flex;
|
| 324 |
+
align-items: center;
|
| 325 |
+
justify-content: center;
|
| 326 |
+
transition: all 0.2s ease;
|
| 327 |
+
padding: 24px;
|
| 328 |
+
border: 1px dashed #d1d5db;
|
| 329 |
+
border-radius: 28px;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
.room-setup-dropzone.dragging {
|
| 333 |
+
background: #f3f4f6;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
.room-setup-file-input {
|
| 337 |
+
position: absolute;
|
| 338 |
+
inset: 0;
|
| 339 |
+
width: 100%;
|
| 340 |
+
height: 100%;
|
| 341 |
+
opacity: 0;
|
| 342 |
+
cursor: pointer;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
.room-setup-drop-content {
|
| 346 |
+
text-align: center;
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
.room-setup-drop-icon {
|
| 350 |
+
display: inline-flex;
|
| 351 |
+
align-items: center;
|
| 352 |
+
justify-content: center;
|
| 353 |
+
width: 72px;
|
| 354 |
+
height: 72px;
|
| 355 |
+
border-radius: 999px;
|
| 356 |
+
background: #e2e8f0;
|
| 357 |
+
color: #64748b;
|
| 358 |
+
margin-bottom: 18px;
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
.room-setup-drop-icon.active {
|
| 362 |
+
background: #dbeafe;
|
| 363 |
+
color: #1e293b;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
.room-setup-drop-content h3 {
|
| 367 |
+
margin: 0 0 8px;
|
| 368 |
+
font-size: 1.2rem;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
.room-setup-drop-content p,
|
| 372 |
+
.room-setup-drop-content small {
|
| 373 |
+
margin: 0;
|
| 374 |
+
color: #64748b;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
.room-setup-preview {
|
| 378 |
+
position: relative;
|
| 379 |
+
width: 100%;
|
| 380 |
+
height: 100%;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
.room-setup-preview img {
|
| 384 |
+
width: 100%;
|
| 385 |
+
height: 100%;
|
| 386 |
+
object-fit: cover;
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
.room-setup-preview-overlay {
|
| 390 |
+
position: absolute;
|
| 391 |
+
inset: 0;
|
| 392 |
+
background: rgba(255, 255, 255, 0.85);
|
| 393 |
+
display: flex;
|
| 394 |
+
align-items: center;
|
| 395 |
+
justify-content: center;
|
| 396 |
+
opacity: 0;
|
| 397 |
+
transition: opacity 0.2s ease;
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
.room-setup-preview:hover .room-setup-preview-overlay {
|
| 401 |
+
opacity: 1;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.button-delete {
|
| 405 |
+
background: #ffffff;
|
| 406 |
+
color: #111827;
|
| 407 |
+
padding: 12px 16px;
|
| 408 |
+
border-radius: 14px;
|
| 409 |
+
font-weight: 600;
|
| 410 |
+
border: 1px solid #d1d5db;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
.room-setup-bottom h2 {
|
| 414 |
+
margin: 0 0 24px;
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
.room-setup-filters {
|
| 418 |
+
display: flex;
|
| 419 |
+
flex-wrap: wrap;
|
| 420 |
+
gap: 10px;
|
| 421 |
+
margin-bottom: 24px;
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
.room-setup-filter {
|
| 425 |
+
border: 1px solid #d1d5db;
|
| 426 |
+
background: #ffffff;
|
| 427 |
+
color: #334155;
|
| 428 |
+
padding: 10px 16px;
|
| 429 |
+
border-radius: 16px;
|
| 430 |
+
cursor: pointer;
|
| 431 |
+
transition: all 0.2s ease;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
.room-setup-filter.active {
|
| 435 |
+
background: #f3f4f6;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
.room-setup-grid {
|
| 439 |
+
display: grid;
|
| 440 |
+
grid-template-columns: repeat(1, minmax(0, 1fr));
|
| 441 |
+
gap: 20px;
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
.room-setup-card {
|
| 445 |
+
overflow: hidden;
|
| 446 |
+
border-radius: 24px;
|
| 447 |
+
background: #ffffff;
|
| 448 |
+
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
.room-setup-card-image {
|
| 452 |
+
position: relative;
|
| 453 |
+
aspect-ratio: 4 / 3;
|
| 454 |
+
overflow: hidden;
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
.room-setup-card-image img {
|
| 458 |
+
width: 100%;
|
| 459 |
+
height: 100%;
|
| 460 |
+
object-fit: cover;
|
| 461 |
+
transition: transform 0.4s ease;
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
.room-setup-card:hover .room-setup-card-image img {
|
| 465 |
+
transform: scale(1.04);
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
.room-setup-card h3 {
|
| 469 |
+
margin: 16px;
|
| 470 |
+
font-size: 1rem;
|
| 471 |
+
color: #334155;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
@media (max-width: 960px) {
|
| 475 |
+
.room-setup-top {
|
| 476 |
+
grid-template-columns: 1fr;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
.room-setup-grid {
|
| 480 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 481 |
+
}
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
@media (max-width: 640px) {
|
| 485 |
+
.room-setup-inner {
|
| 486 |
+
padding: 20px 16px;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
.room-setup-grid {
|
| 490 |
+
grid-template-columns: 1fr;
|
| 491 |
+
}
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
.app-version-badge {
|
| 495 |
+
position: fixed;
|
| 496 |
+
right: 0;
|
| 497 |
+
bottom: 0;
|
| 498 |
+
padding: 6px 10px;
|
| 499 |
+
border-radius: 12px 0 0 0;
|
| 500 |
+
background: rgba(255, 255, 255, 0.95);
|
| 501 |
+
border: 1px solid #e5e7eb;
|
| 502 |
+
color: #475569;
|
| 503 |
+
font-size: 0.78rem;
|
| 504 |
+
text-align: right;
|
| 505 |
+
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
|
| 506 |
+
z-index: 1000;
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
.app-version-badge:hover {
|
| 510 |
+
opacity: 1;
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
.app-version-badge span {
|
| 514 |
+
display: block;
|
| 515 |
+
font-weight: 700;
|
| 516 |
+
color: #111827;
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
.app-version-badge small {
|
| 520 |
+
display: block;
|
| 521 |
+
margin-top: 1px;
|
| 522 |
+
color: #6b7280;
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
@media (max-width: 640px) {
|
| 526 |
+
.app-version-badge {
|
| 527 |
+
right: 6px;
|
| 528 |
+
bottom: 6px;
|
| 529 |
+
padding: 5px 8px;
|
| 530 |
+
font-size: 0.72rem;
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
.app-version-badge small {
|
| 534 |
+
margin-top: 0.5px;
|
| 535 |
+
}
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
@media (max-width: 900px) {
|
| 539 |
+
.content {
|
| 540 |
+
grid-template-columns: 1fr;
|
| 541 |
+
}
|
| 542 |
+
}
|
frontend/src/App.tsx
CHANGED
|
@@ -1,20 +1,20 @@
|
|
| 1 |
-
import { Routes, Route, Navigate } from "react-router-dom";
|
| 2 |
-
import SettingsPage from "./features/settings/SettingsPage";
|
| 3 |
-
import RoomSetup from "./features/roomSetup/RoomSetup";
|
| 4 |
-
import RoomVisualizer from "./features/roomVisualizer/RoomVisualizer";
|
| 5 |
-
import SharedView from "./features/shared/SharedView";
|
| 6 |
-
import "./App.css";
|
| 7 |
-
|
| 8 |
-
export default function App() {
|
| 9 |
-
return (
|
| 10 |
-
<div className="min-h-screen bg-[#f4f8ff] text-[#333333]">
|
| 11 |
-
<Routes>
|
| 12 |
-
<Route path="/" element={<RoomSetup />} />
|
| 13 |
-
<Route path="/visualizer" element={<RoomVisualizer />} />
|
| 14 |
-
<Route path="/share/:shareId" element={<SharedView />} />
|
| 15 |
-
<Route path="/settings" element={<SettingsPage />} />
|
| 16 |
-
<Route path="*" element={<Navigate to="/" replace />} />
|
| 17 |
-
</Routes>
|
| 18 |
-
</div>
|
| 19 |
-
);
|
| 20 |
-
}
|
|
|
|
| 1 |
+
import { Routes, Route, Navigate } from "react-router-dom";
|
| 2 |
+
import SettingsPage from "./features/settings/SettingsPage";
|
| 3 |
+
import RoomSetup from "./features/roomSetup/RoomSetup";
|
| 4 |
+
import RoomVisualizer from "./features/roomVisualizer/RoomVisualizer";
|
| 5 |
+
import SharedView from "./features/shared/SharedView";
|
| 6 |
+
import "./App.css";
|
| 7 |
+
|
| 8 |
+
export default function App() {
|
| 9 |
+
return (
|
| 10 |
+
<div className="min-h-screen bg-[#f4f8ff] text-[#333333]">
|
| 11 |
+
<Routes>
|
| 12 |
+
<Route path="/" element={<RoomSetup />} />
|
| 13 |
+
<Route path="/visualizer" element={<RoomVisualizer />} />
|
| 14 |
+
<Route path="/share/:shareId" element={<SharedView />} />
|
| 15 |
+
<Route path="/settings" element={<SettingsPage />} />
|
| 16 |
+
<Route path="*" element={<Navigate to="/" replace />} />
|
| 17 |
+
</Routes>
|
| 18 |
+
</div>
|
| 19 |
+
);
|
| 20 |
+
}
|
frontend/src/api/client.ts
CHANGED
|
@@ -1,100 +1,100 @@
|
|
| 1 |
-
import type { ClientData } from "../types";
|
| 2 |
-
|
| 3 |
-
export const DEV_API_BASE = "http://localhost:8000";
|
| 4 |
-
export const API_BASE =
|
| 5 |
-
import.meta.env.VITE_API_BASE_URL ??
|
| 6 |
-
(import.meta.env.DEV ? DEV_API_BASE : "");
|
| 7 |
-
const DEV_CLIENT_ID = "ID_UNICO_DEL_CLIENTE_001";
|
| 8 |
-
|
| 9 |
-
export interface ClientConfigResponse {
|
| 10 |
-
cliente: ClientData;
|
| 11 |
-
query: string;
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
export function getApiBase(customApiBase?: string) {
|
| 15 |
-
return customApiBase?.trim() ? customApiBase : API_BASE;
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
export function buildApiUrl(path: string, customApiBase?: string) {
|
| 19 |
-
return `${getApiBase(customApiBase)}${path}`;
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
export async function fetchClientConfig(
|
| 23 |
-
token: string,
|
| 24 |
-
clientId: string,
|
| 25 |
-
customApiBase?: string,
|
| 26 |
-
useDevToken = true,
|
| 27 |
-
): Promise<ClientConfigResponse> {
|
| 28 |
-
let finalToken = token;
|
| 29 |
-
const finalClientId = clientId;
|
| 30 |
-
|
| 31 |
-
if (!finalToken && !finalClientId) {
|
| 32 |
-
if (!import.meta.env.DEV || !useDevToken) {
|
| 33 |
-
throw new Error("No se encontró el token o key en la URL.");
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
const tokenRes = await fetch(buildApiUrl("/api/token", customApiBase), {
|
| 37 |
-
method: "POST",
|
| 38 |
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
| 39 |
-
body: `client_id=${encodeURIComponent(DEV_CLIENT_ID)}`,
|
| 40 |
-
});
|
| 41 |
-
|
| 42 |
-
if (!tokenRes.ok) {
|
| 43 |
-
throw new Error("No se pudo generar el token de prueba en desarrollo.");
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
const tokenData = await tokenRes.json();
|
| 47 |
-
finalToken = tokenData.token;
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
const query = finalToken
|
| 51 |
-
? `token=${encodeURIComponent(finalToken)}`
|
| 52 |
-
: `client_id=${encodeURIComponent(finalClientId)}`;
|
| 53 |
-
|
| 54 |
-
const res = await fetch(buildApiUrl(`/config?${query}`, customApiBase));
|
| 55 |
-
if (!res.ok) {
|
| 56 |
-
const text = await res.text();
|
| 57 |
-
throw new Error(
|
| 58 |
-
`No se pudo obtener la configuración del cliente. ${res.status} ${text}`,
|
| 59 |
-
);
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
const cliente = await res.json();
|
| 63 |
-
return { cliente, query };
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
export interface UploadImageResponse {
|
| 67 |
-
filename: string;
|
| 68 |
-
[key: string]: unknown;
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
-
export async function uploadRoomImage(
|
| 72 |
-
file: File,
|
| 73 |
-
customApiBase?: string,
|
| 74 |
-
): Promise<UploadImageResponse> {
|
| 75 |
-
const formData = new FormData();
|
| 76 |
-
formData.append("file", file);
|
| 77 |
-
|
| 78 |
-
const response = await fetch(
|
| 79 |
-
buildApiUrl("/api/upload-image", customApiBase),
|
| 80 |
-
{
|
| 81 |
-
method: "POST",
|
| 82 |
-
body: formData,
|
| 83 |
-
},
|
| 84 |
-
);
|
| 85 |
-
|
| 86 |
-
if (!response.ok) {
|
| 87 |
-
const text = await response.text();
|
| 88 |
-
throw new Error(text || "Error al subir la imagen");
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
return response.json();
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
export async function startSession(query: string, customApiBase?: string) {
|
| 95 |
-
await fetch(buildApiUrl("/session/start", customApiBase), {
|
| 96 |
-
method: "POST",
|
| 97 |
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
| 98 |
-
body: query,
|
| 99 |
-
});
|
| 100 |
-
}
|
|
|
|
| 1 |
+
import type { ClientData } from "../types";
|
| 2 |
+
|
| 3 |
+
export const DEV_API_BASE = "http://localhost:8000";
|
| 4 |
+
export const API_BASE =
|
| 5 |
+
import.meta.env.VITE_API_BASE_URL ??
|
| 6 |
+
(import.meta.env.DEV ? DEV_API_BASE : "");
|
| 7 |
+
const DEV_CLIENT_ID = "ID_UNICO_DEL_CLIENTE_001";
|
| 8 |
+
|
| 9 |
+
export interface ClientConfigResponse {
|
| 10 |
+
cliente: ClientData;
|
| 11 |
+
query: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function getApiBase(customApiBase?: string) {
|
| 15 |
+
return customApiBase?.trim() ? customApiBase : API_BASE;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function buildApiUrl(path: string, customApiBase?: string) {
|
| 19 |
+
return `${getApiBase(customApiBase)}${path}`;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export async function fetchClientConfig(
|
| 23 |
+
token: string,
|
| 24 |
+
clientId: string,
|
| 25 |
+
customApiBase?: string,
|
| 26 |
+
useDevToken = true,
|
| 27 |
+
): Promise<ClientConfigResponse> {
|
| 28 |
+
let finalToken = token;
|
| 29 |
+
const finalClientId = clientId;
|
| 30 |
+
|
| 31 |
+
if (!finalToken && !finalClientId) {
|
| 32 |
+
if (!import.meta.env.DEV || !useDevToken) {
|
| 33 |
+
throw new Error("No se encontró el token o key en la URL.");
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const tokenRes = await fetch(buildApiUrl("/api/token", customApiBase), {
|
| 37 |
+
method: "POST",
|
| 38 |
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
| 39 |
+
body: `client_id=${encodeURIComponent(DEV_CLIENT_ID)}`,
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
if (!tokenRes.ok) {
|
| 43 |
+
throw new Error("No se pudo generar el token de prueba en desarrollo.");
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
const tokenData = await tokenRes.json();
|
| 47 |
+
finalToken = tokenData.token;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const query = finalToken
|
| 51 |
+
? `token=${encodeURIComponent(finalToken)}`
|
| 52 |
+
: `client_id=${encodeURIComponent(finalClientId)}`;
|
| 53 |
+
|
| 54 |
+
const res = await fetch(buildApiUrl(`/config?${query}`, customApiBase));
|
| 55 |
+
if (!res.ok) {
|
| 56 |
+
const text = await res.text();
|
| 57 |
+
throw new Error(
|
| 58 |
+
`No se pudo obtener la configuración del cliente. ${res.status} ${text}`,
|
| 59 |
+
);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const cliente = await res.json();
|
| 63 |
+
return { cliente, query };
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export interface UploadImageResponse {
|
| 67 |
+
filename: string;
|
| 68 |
+
[key: string]: unknown;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
export async function uploadRoomImage(
|
| 72 |
+
file: File,
|
| 73 |
+
customApiBase?: string,
|
| 74 |
+
): Promise<UploadImageResponse> {
|
| 75 |
+
const formData = new FormData();
|
| 76 |
+
formData.append("file", file);
|
| 77 |
+
|
| 78 |
+
const response = await fetch(
|
| 79 |
+
buildApiUrl("/api/upload-image", customApiBase),
|
| 80 |
+
{
|
| 81 |
+
method: "POST",
|
| 82 |
+
body: formData,
|
| 83 |
+
},
|
| 84 |
+
);
|
| 85 |
+
|
| 86 |
+
if (!response.ok) {
|
| 87 |
+
const text = await response.text();
|
| 88 |
+
throw new Error(text || "Error al subir la imagen");
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
return response.json();
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
export async function startSession(query: string, customApiBase?: string) {
|
| 95 |
+
await fetch(buildApiUrl("/session/start", customApiBase), {
|
| 96 |
+
method: "POST",
|
| 97 |
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
| 98 |
+
body: query,
|
| 99 |
+
});
|
| 100 |
+
}
|
frontend/src/assets/vite.svg
CHANGED
|
|
|
|
frontend/src/components/ui/LoadingScreen.tsx
CHANGED
|
@@ -1,33 +1,33 @@
|
|
| 1 |
-
import { Image } from "lucide-react";
|
| 2 |
-
|
| 3 |
-
export default function LoadingScreen() {
|
| 4 |
-
return (
|
| 5 |
-
<div className="fixed inset-0 flex flex-col items-center justify-center bg-zinc-400 z-50">
|
| 6 |
-
<div className="relative flex flex-col items-center">
|
| 7 |
-
<div className="relative mb-8 animate-pulse">
|
| 8 |
-
<div className="text-white">
|
| 9 |
-
<Image size={110} strokeWidth={1.2} className="drop-shadow-sm" />
|
| 10 |
-
</div>
|
| 11 |
-
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-14 h-1.5 bg-yellow-400 rounded-full shadow-md" />
|
| 12 |
-
</div>
|
| 13 |
-
|
| 14 |
-
<div className="text-center">
|
| 15 |
-
<p className="text-white text-xl font-light tracking-widest uppercase opacity-90 animate-pulse">
|
| 16 |
-
Encontrar los mejores productos
|
| 17 |
-
</p>
|
| 18 |
-
<div className="mt-8 flex gap-2 justify-center">
|
| 19 |
-
<div className="w-2 h-2 bg-white rounded-full animate-bounce [animation-delay:-0.3s]" />
|
| 20 |
-
<div className="w-2 h-2 bg-white rounded-full animate-bounce [animation-delay:-0.15s]" />
|
| 21 |
-
<div className="w-2 h-2 bg-white rounded-full animate-bounce" />
|
| 22 |
-
</div>
|
| 23 |
-
</div>
|
| 24 |
-
</div>
|
| 25 |
-
|
| 26 |
-
<div className="absolute bottom-10 opacity-30">
|
| 27 |
-
<p className="text-white text-xs tracking-tighter uppercase font-bold">
|
| 28 |
-
Cargando Catálogo v2.0
|
| 29 |
-
</p>
|
| 30 |
-
</div>
|
| 31 |
-
</div>
|
| 32 |
-
);
|
| 33 |
-
}
|
|
|
|
| 1 |
+
import { Image } from "lucide-react";
|
| 2 |
+
|
| 3 |
+
export default function LoadingScreen() {
|
| 4 |
+
return (
|
| 5 |
+
<div className="fixed inset-0 flex flex-col items-center justify-center bg-zinc-400 z-50">
|
| 6 |
+
<div className="relative flex flex-col items-center">
|
| 7 |
+
<div className="relative mb-8 animate-pulse">
|
| 8 |
+
<div className="text-white">
|
| 9 |
+
<Image size={110} strokeWidth={1.2} className="drop-shadow-sm" />
|
| 10 |
+
</div>
|
| 11 |
+
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-14 h-1.5 bg-yellow-400 rounded-full shadow-md" />
|
| 12 |
+
</div>
|
| 13 |
+
|
| 14 |
+
<div className="text-center">
|
| 15 |
+
<p className="text-white text-xl font-light tracking-widest uppercase opacity-90 animate-pulse">
|
| 16 |
+
Encontrar los mejores productos
|
| 17 |
+
</p>
|
| 18 |
+
<div className="mt-8 flex gap-2 justify-center">
|
| 19 |
+
<div className="w-2 h-2 bg-white rounded-full animate-bounce [animation-delay:-0.3s]" />
|
| 20 |
+
<div className="w-2 h-2 bg-white rounded-full animate-bounce [animation-delay:-0.15s]" />
|
| 21 |
+
<div className="w-2 h-2 bg-white rounded-full animate-bounce" />
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
</div>
|
| 25 |
+
|
| 26 |
+
<div className="absolute bottom-10 opacity-30">
|
| 27 |
+
<p className="text-white text-xs tracking-tighter uppercase font-bold">
|
| 28 |
+
Cargando Catálogo v2.0
|
| 29 |
+
</p>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
);
|
| 33 |
+
}
|
frontend/src/data/roomSetupData.ts
CHANGED
|
@@ -1,329 +1,329 @@
|
|
| 1 |
-
export type RoomCategory = {
|
| 2 |
-
id: string;
|
| 3 |
-
label: string;
|
| 4 |
-
count: number;
|
| 5 |
-
};
|
| 6 |
-
|
| 7 |
-
export type RoomItem = {
|
| 8 |
-
id: number;
|
| 9 |
-
title: string;
|
| 10 |
-
category: string;
|
| 11 |
-
img: string;
|
| 12 |
-
};
|
| 13 |
-
|
| 14 |
-
export const categorias: RoomCategory[] = [
|
| 15 |
-
{ id: "todos", label: "Todos", count: 33 },
|
| 16 |
-
{ id: "bano", label: "Baño", count: 10 },
|
| 17 |
-
{ id: "cocina", label: "Cocina", count: 2 },
|
| 18 |
-
{ id: "comedor", label: "Comedor", count: 3 },
|
| 19 |
-
{ id: "dormitorio", label: "Dormitorio", count: 5 },
|
| 20 |
-
{ id: "espacio_abierto", label: "Espacio abierto", count: 7 },
|
| 21 |
-
{ id: "sala", label: "Sala de estar", count: 1 },
|
| 22 |
-
{ id: "terraza", label: "Terraza", count: 5 },
|
| 23 |
-
];
|
| 24 |
-
|
| 25 |
-
export const habitaciones: RoomItem[] = [
|
| 26 |
-
{
|
| 27 |
-
id: 1,
|
| 28 |
-
title: "Refugio de Spa Contemporáneo",
|
| 29 |
-
category: "bano",
|
| 30 |
-
img: new URL(
|
| 31 |
-
"../assets/imagens-default-room/bathroom/bathroom1.png",
|
| 32 |
-
import.meta.url,
|
| 33 |
-
).href,
|
| 34 |
-
},
|
| 35 |
-
{
|
| 36 |
-
id: 2,
|
| 37 |
-
title: "Oasis de Mármol Sereno",
|
| 38 |
-
category: "bano",
|
| 39 |
-
img: new URL(
|
| 40 |
-
"../assets/imagens-default-room/bathroom/bathroom2.png",
|
| 41 |
-
import.meta.url,
|
| 42 |
-
).href,
|
| 43 |
-
},
|
| 44 |
-
{
|
| 45 |
-
id: 3,
|
| 46 |
-
title: "Baño Zen de Luz Natural",
|
| 47 |
-
category: "bano",
|
| 48 |
-
img: new URL(
|
| 49 |
-
"../assets/imagens-default-room/bathroom/bathroom3.png",
|
| 50 |
-
import.meta.url,
|
| 51 |
-
).href,
|
| 52 |
-
},
|
| 53 |
-
{
|
| 54 |
-
id: 4,
|
| 55 |
-
title: "Retiro de Spa Blanco",
|
| 56 |
-
category: "bano",
|
| 57 |
-
img: new URL(
|
| 58 |
-
"../assets/imagens-default-room/bathroom/bathroom4.png",
|
| 59 |
-
import.meta.url,
|
| 60 |
-
).href,
|
| 61 |
-
},
|
| 62 |
-
{
|
| 63 |
-
id: 5,
|
| 64 |
-
title: "Lavabo de Encanto Minimalista",
|
| 65 |
-
category: "bano",
|
| 66 |
-
img: new URL(
|
| 67 |
-
"../assets/imagens-default-room/bathroom/bathroom5.png",
|
| 68 |
-
import.meta.url,
|
| 69 |
-
).href,
|
| 70 |
-
},
|
| 71 |
-
{
|
| 72 |
-
id: 6,
|
| 73 |
-
title: "Baño Escandinavo Refinado",
|
| 74 |
-
category: "bano",
|
| 75 |
-
img: new URL(
|
| 76 |
-
"../assets/imagens-default-room/bathroom/bathroom6.png",
|
| 77 |
-
import.meta.url,
|
| 78 |
-
).href,
|
| 79 |
-
},
|
| 80 |
-
{
|
| 81 |
-
id: 7,
|
| 82 |
-
title: "Santuario de Mármol Pálido",
|
| 83 |
-
category: "bano",
|
| 84 |
-
img: new URL(
|
| 85 |
-
"../assets/imagens-default-room/bathroom/bathroom7.png",
|
| 86 |
-
import.meta.url,
|
| 87 |
-
).href,
|
| 88 |
-
},
|
| 89 |
-
{
|
| 90 |
-
id: 8,
|
| 91 |
-
title: "Oasis Sereno de Blanco y Luz",
|
| 92 |
-
category: "bano",
|
| 93 |
-
img: new URL(
|
| 94 |
-
"../assets/imagens-default-room/bathroom/bathroom8.png",
|
| 95 |
-
import.meta.url,
|
| 96 |
-
).href,
|
| 97 |
-
},
|
| 98 |
-
{
|
| 99 |
-
id: 9,
|
| 100 |
-
title: "Retiro de Mármol y Madera",
|
| 101 |
-
category: "bano",
|
| 102 |
-
img: new URL(
|
| 103 |
-
"../assets/imagens-default-room/bathroom/bathroom9.png",
|
| 104 |
-
import.meta.url,
|
| 105 |
-
).href,
|
| 106 |
-
},
|
| 107 |
-
{
|
| 108 |
-
id: 10,
|
| 109 |
-
title: "Baño de Luz Cálida",
|
| 110 |
-
category: "bano",
|
| 111 |
-
img: new URL(
|
| 112 |
-
"../assets/imagens-default-room/bathroom/bathroom10.png",
|
| 113 |
-
import.meta.url,
|
| 114 |
-
).href,
|
| 115 |
-
},
|
| 116 |
-
|
| 117 |
-
{
|
| 118 |
-
id: 11,
|
| 119 |
-
title: "Cocina Gourmet de Autor",
|
| 120 |
-
category: "cocina",
|
| 121 |
-
img: new URL(
|
| 122 |
-
"../assets/imagens-default-room/Kitchen/kitchen1.png",
|
| 123 |
-
import.meta.url,
|
| 124 |
-
).href,
|
| 125 |
-
},
|
| 126 |
-
{
|
| 127 |
-
id: 12,
|
| 128 |
-
title: "Cocina Contemporánea Urbana",
|
| 129 |
-
category: "cocina",
|
| 130 |
-
img: new URL(
|
| 131 |
-
"../assets/imagens-default-room/Kitchen/kitchen2.png",
|
| 132 |
-
import.meta.url,
|
| 133 |
-
).href,
|
| 134 |
-
},
|
| 135 |
-
|
| 136 |
-
{
|
| 137 |
-
id: 13,
|
| 138 |
-
title: "Comedor de Gala Moderno",
|
| 139 |
-
category: "comedor",
|
| 140 |
-
img: new URL(
|
| 141 |
-
"../assets/imagens-default-room/dining room/diningroom1.png",
|
| 142 |
-
import.meta.url,
|
| 143 |
-
).href,
|
| 144 |
-
},
|
| 145 |
-
{
|
| 146 |
-
id: 14,
|
| 147 |
-
title: "Comedor Clásico de Lujo",
|
| 148 |
-
category: "comedor",
|
| 149 |
-
img: new URL(
|
| 150 |
-
"../assets/imagens-default-room/dining room/diningroom2.png",
|
| 151 |
-
import.meta.url,
|
| 152 |
-
).href,
|
| 153 |
-
},
|
| 154 |
-
{
|
| 155 |
-
id: 15,
|
| 156 |
-
title: "Sala de Banquetes Minimalista",
|
| 157 |
-
category: "comedor",
|
| 158 |
-
img: new URL(
|
| 159 |
-
"../assets/imagens-default-room/dining room/diningroom3.png",
|
| 160 |
-
import.meta.url,
|
| 161 |
-
).href,
|
| 162 |
-
},
|
| 163 |
-
|
| 164 |
-
{
|
| 165 |
-
id: 16,
|
| 166 |
-
title: "Suite del Amanecer",
|
| 167 |
-
category: "dormitorio",
|
| 168 |
-
img: new URL(
|
| 169 |
-
"../assets/imagens-default-room/bedroom/bedroom1.png",
|
| 170 |
-
import.meta.url,
|
| 171 |
-
).href,
|
| 172 |
-
},
|
| 173 |
-
{
|
| 174 |
-
id: 17,
|
| 175 |
-
title: "Dormitorio de Seda",
|
| 176 |
-
category: "dormitorio",
|
| 177 |
-
img: new URL(
|
| 178 |
-
"../assets/imagens-default-room/bedroom/bedroom2.png",
|
| 179 |
-
import.meta.url,
|
| 180 |
-
).href,
|
| 181 |
-
},
|
| 182 |
-
{
|
| 183 |
-
id: 18,
|
| 184 |
-
title: "Alcoba Tranquila",
|
| 185 |
-
category: "dormitorio",
|
| 186 |
-
img: new URL(
|
| 187 |
-
"../assets/imagens-default-room/bedroom/bedroom3.png",
|
| 188 |
-
import.meta.url,
|
| 189 |
-
).href,
|
| 190 |
-
},
|
| 191 |
-
{
|
| 192 |
-
id: 19,
|
| 193 |
-
title: "Retiro de Noche Azul",
|
| 194 |
-
category: "dormitorio",
|
| 195 |
-
img: new URL(
|
| 196 |
-
"../assets/imagens-default-room/bedroom/bedroom4.png",
|
| 197 |
-
import.meta.url,
|
| 198 |
-
).href,
|
| 199 |
-
},
|
| 200 |
-
{
|
| 201 |
-
id: 20,
|
| 202 |
-
title: "Quinta Esencia del Descanso",
|
| 203 |
-
category: "dormitorio",
|
| 204 |
-
img: new URL(
|
| 205 |
-
"../assets/imagens-default-room/bedroom/bedroom5.png",
|
| 206 |
-
import.meta.url,
|
| 207 |
-
).href,
|
| 208 |
-
},
|
| 209 |
-
|
| 210 |
-
{
|
| 211 |
-
id: 21,
|
| 212 |
-
title: "Salón Contemporáneo Sereno",
|
| 213 |
-
category: "sala",
|
| 214 |
-
img: new URL(
|
| 215 |
-
"../assets/imagens-default-room/living room/livingroom1.png",
|
| 216 |
-
import.meta.url,
|
| 217 |
-
).href,
|
| 218 |
-
},
|
| 219 |
-
|
| 220 |
-
{
|
| 221 |
-
id: 22,
|
| 222 |
-
title: "Loft de Claridad",
|
| 223 |
-
category: "espacio_abierto",
|
| 224 |
-
img: new URL(
|
| 225 |
-
"../assets/imagens-default-room/open space/openspace1.png",
|
| 226 |
-
import.meta.url,
|
| 227 |
-
).href,
|
| 228 |
-
},
|
| 229 |
-
{
|
| 230 |
-
id: 23,
|
| 231 |
-
title: "Galería Abierta Suave",
|
| 232 |
-
category: "espacio_abierto",
|
| 233 |
-
img: new URL(
|
| 234 |
-
"../assets/imagens-default-room/open space/openspace2.png",
|
| 235 |
-
import.meta.url,
|
| 236 |
-
).href,
|
| 237 |
-
},
|
| 238 |
-
{
|
| 239 |
-
id: 24,
|
| 240 |
-
title: "Ático de Luz Serena",
|
| 241 |
-
category: "espacio_abierto",
|
| 242 |
-
img: new URL(
|
| 243 |
-
"../assets/imagens-default-room/open space/openspace3.png",
|
| 244 |
-
import.meta.url,
|
| 245 |
-
).href,
|
| 246 |
-
},
|
| 247 |
-
{
|
| 248 |
-
id: 25,
|
| 249 |
-
title: "Salón Multifuncional Luminoso",
|
| 250 |
-
category: "espacio_abierto",
|
| 251 |
-
img: new URL(
|
| 252 |
-
"../assets/imagens-default-room/open space/openspace4.png",
|
| 253 |
-
import.meta.url,
|
| 254 |
-
).href,
|
| 255 |
-
},
|
| 256 |
-
{
|
| 257 |
-
id: 26,
|
| 258 |
-
title: "Atrio de Cielo Blanco",
|
| 259 |
-
category: "espacio_abierto",
|
| 260 |
-
img: new URL(
|
| 261 |
-
"../assets/imagens-default-room/open space/openspace5.png",
|
| 262 |
-
import.meta.url,
|
| 263 |
-
).href,
|
| 264 |
-
},
|
| 265 |
-
{
|
| 266 |
-
id: 27,
|
| 267 |
-
title: "Estudio de Altura",
|
| 268 |
-
category: "espacio_abierto",
|
| 269 |
-
img: new URL(
|
| 270 |
-
"../assets/imagens-default-room/open space/openspace6.png",
|
| 271 |
-
import.meta.url,
|
| 272 |
-
).href,
|
| 273 |
-
},
|
| 274 |
-
{
|
| 275 |
-
id: 28,
|
| 276 |
-
title: "Retiro Etéreo",
|
| 277 |
-
category: "espacio_abierto",
|
| 278 |
-
img: new URL(
|
| 279 |
-
"../assets/imagens-default-room/open space/openspace7.png",
|
| 280 |
-
import.meta.url,
|
| 281 |
-
).href,
|
| 282 |
-
},
|
| 283 |
-
|
| 284 |
-
{
|
| 285 |
-
id: 29,
|
| 286 |
-
title: "Terraza Nocturna de Ciudad",
|
| 287 |
-
category: "terraza",
|
| 288 |
-
img: new URL(
|
| 289 |
-
"../assets/imagens-default-room/terrace/terrace1.png",
|
| 290 |
-
import.meta.url,
|
| 291 |
-
).href,
|
| 292 |
-
},
|
| 293 |
-
{
|
| 294 |
-
id: 30,
|
| 295 |
-
title: "Terraza Contemporánea",
|
| 296 |
-
category: "terraza",
|
| 297 |
-
img: new URL(
|
| 298 |
-
"../assets/imagens-default-room/terrace/terrace2.png",
|
| 299 |
-
import.meta.url,
|
| 300 |
-
).href,
|
| 301 |
-
},
|
| 302 |
-
{
|
| 303 |
-
id: 31,
|
| 304 |
-
title: "Terraza de Rooftop Chic",
|
| 305 |
-
category: "terraza",
|
| 306 |
-
img: new URL(
|
| 307 |
-
"../assets/imagens-default-room/terrace/terrace3.png",
|
| 308 |
-
import.meta.url,
|
| 309 |
-
).href,
|
| 310 |
-
},
|
| 311 |
-
{
|
| 312 |
-
id: 32,
|
| 313 |
-
title: "Terraza de Diseño Iluminado",
|
| 314 |
-
category: "terraza",
|
| 315 |
-
img: new URL(
|
| 316 |
-
"../assets/imagens-default-room/terrace/terrace4.png",
|
| 317 |
-
import.meta.url,
|
| 318 |
-
).href,
|
| 319 |
-
},
|
| 320 |
-
{
|
| 321 |
-
id: 33,
|
| 322 |
-
title: "Terraza de Encanto Nocturno",
|
| 323 |
-
category: "terraza",
|
| 324 |
-
img: new URL(
|
| 325 |
-
"../assets/imagens-default-room/terrace/terrace5.png",
|
| 326 |
-
import.meta.url,
|
| 327 |
-
).href,
|
| 328 |
-
},
|
| 329 |
-
];
|
|
|
|
| 1 |
+
export type RoomCategory = {
|
| 2 |
+
id: string;
|
| 3 |
+
label: string;
|
| 4 |
+
count: number;
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export type RoomItem = {
|
| 8 |
+
id: number;
|
| 9 |
+
title: string;
|
| 10 |
+
category: string;
|
| 11 |
+
img: string;
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
export const categorias: RoomCategory[] = [
|
| 15 |
+
{ id: "todos", label: "Todos", count: 33 },
|
| 16 |
+
{ id: "bano", label: "Baño", count: 10 },
|
| 17 |
+
{ id: "cocina", label: "Cocina", count: 2 },
|
| 18 |
+
{ id: "comedor", label: "Comedor", count: 3 },
|
| 19 |
+
{ id: "dormitorio", label: "Dormitorio", count: 5 },
|
| 20 |
+
{ id: "espacio_abierto", label: "Espacio abierto", count: 7 },
|
| 21 |
+
{ id: "sala", label: "Sala de estar", count: 1 },
|
| 22 |
+
{ id: "terraza", label: "Terraza", count: 5 },
|
| 23 |
+
];
|
| 24 |
+
|
| 25 |
+
export const habitaciones: RoomItem[] = [
|
| 26 |
+
{
|
| 27 |
+
id: 1,
|
| 28 |
+
title: "Refugio de Spa Contemporáneo",
|
| 29 |
+
category: "bano",
|
| 30 |
+
img: new URL(
|
| 31 |
+
"../assets/imagens-default-room/bathroom/bathroom1.png",
|
| 32 |
+
import.meta.url,
|
| 33 |
+
).href,
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
id: 2,
|
| 37 |
+
title: "Oasis de Mármol Sereno",
|
| 38 |
+
category: "bano",
|
| 39 |
+
img: new URL(
|
| 40 |
+
"../assets/imagens-default-room/bathroom/bathroom2.png",
|
| 41 |
+
import.meta.url,
|
| 42 |
+
).href,
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
id: 3,
|
| 46 |
+
title: "Baño Zen de Luz Natural",
|
| 47 |
+
category: "bano",
|
| 48 |
+
img: new URL(
|
| 49 |
+
"../assets/imagens-default-room/bathroom/bathroom3.png",
|
| 50 |
+
import.meta.url,
|
| 51 |
+
).href,
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
id: 4,
|
| 55 |
+
title: "Retiro de Spa Blanco",
|
| 56 |
+
category: "bano",
|
| 57 |
+
img: new URL(
|
| 58 |
+
"../assets/imagens-default-room/bathroom/bathroom4.png",
|
| 59 |
+
import.meta.url,
|
| 60 |
+
).href,
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
id: 5,
|
| 64 |
+
title: "Lavabo de Encanto Minimalista",
|
| 65 |
+
category: "bano",
|
| 66 |
+
img: new URL(
|
| 67 |
+
"../assets/imagens-default-room/bathroom/bathroom5.png",
|
| 68 |
+
import.meta.url,
|
| 69 |
+
).href,
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
id: 6,
|
| 73 |
+
title: "Baño Escandinavo Refinado",
|
| 74 |
+
category: "bano",
|
| 75 |
+
img: new URL(
|
| 76 |
+
"../assets/imagens-default-room/bathroom/bathroom6.png",
|
| 77 |
+
import.meta.url,
|
| 78 |
+
).href,
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
id: 7,
|
| 82 |
+
title: "Santuario de Mármol Pálido",
|
| 83 |
+
category: "bano",
|
| 84 |
+
img: new URL(
|
| 85 |
+
"../assets/imagens-default-room/bathroom/bathroom7.png",
|
| 86 |
+
import.meta.url,
|
| 87 |
+
).href,
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
id: 8,
|
| 91 |
+
title: "Oasis Sereno de Blanco y Luz",
|
| 92 |
+
category: "bano",
|
| 93 |
+
img: new URL(
|
| 94 |
+
"../assets/imagens-default-room/bathroom/bathroom8.png",
|
| 95 |
+
import.meta.url,
|
| 96 |
+
).href,
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
id: 9,
|
| 100 |
+
title: "Retiro de Mármol y Madera",
|
| 101 |
+
category: "bano",
|
| 102 |
+
img: new URL(
|
| 103 |
+
"../assets/imagens-default-room/bathroom/bathroom9.png",
|
| 104 |
+
import.meta.url,
|
| 105 |
+
).href,
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
id: 10,
|
| 109 |
+
title: "Baño de Luz Cálida",
|
| 110 |
+
category: "bano",
|
| 111 |
+
img: new URL(
|
| 112 |
+
"../assets/imagens-default-room/bathroom/bathroom10.png",
|
| 113 |
+
import.meta.url,
|
| 114 |
+
).href,
|
| 115 |
+
},
|
| 116 |
+
|
| 117 |
+
{
|
| 118 |
+
id: 11,
|
| 119 |
+
title: "Cocina Gourmet de Autor",
|
| 120 |
+
category: "cocina",
|
| 121 |
+
img: new URL(
|
| 122 |
+
"../assets/imagens-default-room/Kitchen/kitchen1.png",
|
| 123 |
+
import.meta.url,
|
| 124 |
+
).href,
|
| 125 |
+
},
|
| 126 |
+
{
|
| 127 |
+
id: 12,
|
| 128 |
+
title: "Cocina Contemporánea Urbana",
|
| 129 |
+
category: "cocina",
|
| 130 |
+
img: new URL(
|
| 131 |
+
"../assets/imagens-default-room/Kitchen/kitchen2.png",
|
| 132 |
+
import.meta.url,
|
| 133 |
+
).href,
|
| 134 |
+
},
|
| 135 |
+
|
| 136 |
+
{
|
| 137 |
+
id: 13,
|
| 138 |
+
title: "Comedor de Gala Moderno",
|
| 139 |
+
category: "comedor",
|
| 140 |
+
img: new URL(
|
| 141 |
+
"../assets/imagens-default-room/dining room/diningroom1.png",
|
| 142 |
+
import.meta.url,
|
| 143 |
+
).href,
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
id: 14,
|
| 147 |
+
title: "Comedor Clásico de Lujo",
|
| 148 |
+
category: "comedor",
|
| 149 |
+
img: new URL(
|
| 150 |
+
"../assets/imagens-default-room/dining room/diningroom2.png",
|
| 151 |
+
import.meta.url,
|
| 152 |
+
).href,
|
| 153 |
+
},
|
| 154 |
+
{
|
| 155 |
+
id: 15,
|
| 156 |
+
title: "Sala de Banquetes Minimalista",
|
| 157 |
+
category: "comedor",
|
| 158 |
+
img: new URL(
|
| 159 |
+
"../assets/imagens-default-room/dining room/diningroom3.png",
|
| 160 |
+
import.meta.url,
|
| 161 |
+
).href,
|
| 162 |
+
},
|
| 163 |
+
|
| 164 |
+
{
|
| 165 |
+
id: 16,
|
| 166 |
+
title: "Suite del Amanecer",
|
| 167 |
+
category: "dormitorio",
|
| 168 |
+
img: new URL(
|
| 169 |
+
"../assets/imagens-default-room/bedroom/bedroom1.png",
|
| 170 |
+
import.meta.url,
|
| 171 |
+
).href,
|
| 172 |
+
},
|
| 173 |
+
{
|
| 174 |
+
id: 17,
|
| 175 |
+
title: "Dormitorio de Seda",
|
| 176 |
+
category: "dormitorio",
|
| 177 |
+
img: new URL(
|
| 178 |
+
"../assets/imagens-default-room/bedroom/bedroom2.png",
|
| 179 |
+
import.meta.url,
|
| 180 |
+
).href,
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
id: 18,
|
| 184 |
+
title: "Alcoba Tranquila",
|
| 185 |
+
category: "dormitorio",
|
| 186 |
+
img: new URL(
|
| 187 |
+
"../assets/imagens-default-room/bedroom/bedroom3.png",
|
| 188 |
+
import.meta.url,
|
| 189 |
+
).href,
|
| 190 |
+
},
|
| 191 |
+
{
|
| 192 |
+
id: 19,
|
| 193 |
+
title: "Retiro de Noche Azul",
|
| 194 |
+
category: "dormitorio",
|
| 195 |
+
img: new URL(
|
| 196 |
+
"../assets/imagens-default-room/bedroom/bedroom4.png",
|
| 197 |
+
import.meta.url,
|
| 198 |
+
).href,
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
id: 20,
|
| 202 |
+
title: "Quinta Esencia del Descanso",
|
| 203 |
+
category: "dormitorio",
|
| 204 |
+
img: new URL(
|
| 205 |
+
"../assets/imagens-default-room/bedroom/bedroom5.png",
|
| 206 |
+
import.meta.url,
|
| 207 |
+
).href,
|
| 208 |
+
},
|
| 209 |
+
|
| 210 |
+
{
|
| 211 |
+
id: 21,
|
| 212 |
+
title: "Salón Contemporáneo Sereno",
|
| 213 |
+
category: "sala",
|
| 214 |
+
img: new URL(
|
| 215 |
+
"../assets/imagens-default-room/living room/livingroom1.png",
|
| 216 |
+
import.meta.url,
|
| 217 |
+
).href,
|
| 218 |
+
},
|
| 219 |
+
|
| 220 |
+
{
|
| 221 |
+
id: 22,
|
| 222 |
+
title: "Loft de Claridad",
|
| 223 |
+
category: "espacio_abierto",
|
| 224 |
+
img: new URL(
|
| 225 |
+
"../assets/imagens-default-room/open space/openspace1.png",
|
| 226 |
+
import.meta.url,
|
| 227 |
+
).href,
|
| 228 |
+
},
|
| 229 |
+
{
|
| 230 |
+
id: 23,
|
| 231 |
+
title: "Galería Abierta Suave",
|
| 232 |
+
category: "espacio_abierto",
|
| 233 |
+
img: new URL(
|
| 234 |
+
"../assets/imagens-default-room/open space/openspace2.png",
|
| 235 |
+
import.meta.url,
|
| 236 |
+
).href,
|
| 237 |
+
},
|
| 238 |
+
{
|
| 239 |
+
id: 24,
|
| 240 |
+
title: "Ático de Luz Serena",
|
| 241 |
+
category: "espacio_abierto",
|
| 242 |
+
img: new URL(
|
| 243 |
+
"../assets/imagens-default-room/open space/openspace3.png",
|
| 244 |
+
import.meta.url,
|
| 245 |
+
).href,
|
| 246 |
+
},
|
| 247 |
+
{
|
| 248 |
+
id: 25,
|
| 249 |
+
title: "Salón Multifuncional Luminoso",
|
| 250 |
+
category: "espacio_abierto",
|
| 251 |
+
img: new URL(
|
| 252 |
+
"../assets/imagens-default-room/open space/openspace4.png",
|
| 253 |
+
import.meta.url,
|
| 254 |
+
).href,
|
| 255 |
+
},
|
| 256 |
+
{
|
| 257 |
+
id: 26,
|
| 258 |
+
title: "Atrio de Cielo Blanco",
|
| 259 |
+
category: "espacio_abierto",
|
| 260 |
+
img: new URL(
|
| 261 |
+
"../assets/imagens-default-room/open space/openspace5.png",
|
| 262 |
+
import.meta.url,
|
| 263 |
+
).href,
|
| 264 |
+
},
|
| 265 |
+
{
|
| 266 |
+
id: 27,
|
| 267 |
+
title: "Estudio de Altura",
|
| 268 |
+
category: "espacio_abierto",
|
| 269 |
+
img: new URL(
|
| 270 |
+
"../assets/imagens-default-room/open space/openspace6.png",
|
| 271 |
+
import.meta.url,
|
| 272 |
+
).href,
|
| 273 |
+
},
|
| 274 |
+
{
|
| 275 |
+
id: 28,
|
| 276 |
+
title: "Retiro Etéreo",
|
| 277 |
+
category: "espacio_abierto",
|
| 278 |
+
img: new URL(
|
| 279 |
+
"../assets/imagens-default-room/open space/openspace7.png",
|
| 280 |
+
import.meta.url,
|
| 281 |
+
).href,
|
| 282 |
+
},
|
| 283 |
+
|
| 284 |
+
{
|
| 285 |
+
id: 29,
|
| 286 |
+
title: "Terraza Nocturna de Ciudad",
|
| 287 |
+
category: "terraza",
|
| 288 |
+
img: new URL(
|
| 289 |
+
"../assets/imagens-default-room/terrace/terrace1.png",
|
| 290 |
+
import.meta.url,
|
| 291 |
+
).href,
|
| 292 |
+
},
|
| 293 |
+
{
|
| 294 |
+
id: 30,
|
| 295 |
+
title: "Terraza Contemporánea",
|
| 296 |
+
category: "terraza",
|
| 297 |
+
img: new URL(
|
| 298 |
+
"../assets/imagens-default-room/terrace/terrace2.png",
|
| 299 |
+
import.meta.url,
|
| 300 |
+
).href,
|
| 301 |
+
},
|
| 302 |
+
{
|
| 303 |
+
id: 31,
|
| 304 |
+
title: "Terraza de Rooftop Chic",
|
| 305 |
+
category: "terraza",
|
| 306 |
+
img: new URL(
|
| 307 |
+
"../assets/imagens-default-room/terrace/terrace3.png",
|
| 308 |
+
import.meta.url,
|
| 309 |
+
).href,
|
| 310 |
+
},
|
| 311 |
+
{
|
| 312 |
+
id: 32,
|
| 313 |
+
title: "Terraza de Diseño Iluminado",
|
| 314 |
+
category: "terraza",
|
| 315 |
+
img: new URL(
|
| 316 |
+
"../assets/imagens-default-room/terrace/terrace4.png",
|
| 317 |
+
import.meta.url,
|
| 318 |
+
).href,
|
| 319 |
+
},
|
| 320 |
+
{
|
| 321 |
+
id: 33,
|
| 322 |
+
title: "Terraza de Encanto Nocturno",
|
| 323 |
+
category: "terraza",
|
| 324 |
+
img: new URL(
|
| 325 |
+
"../assets/imagens-default-room/terrace/terrace5.png",
|
| 326 |
+
import.meta.url,
|
| 327 |
+
).href,
|
| 328 |
+
},
|
| 329 |
+
];
|
frontend/src/features/roomSetup/RoomSetup.tsx
CHANGED
|
@@ -1,334 +1,318 @@
|
|
| 1 |
-
import { useState } from "react";
|
| 2 |
-
import {
|
| 3 |
-
X,
|
| 4 |
-
Camera,
|
| 5 |
-
Package,
|
| 6 |
-
Camera as CameraIcon,
|
| 7 |
-
UploadCloud,
|
| 8 |
-
Users,
|
| 9 |
-
ArrowRight,
|
| 10 |
-
} from "lucide-react";
|
| 11 |
-
import { useNavigate } from "react-router-dom";
|
| 12 |
-
import { categorias, habitaciones } from "../../data/roomSetupData";
|
| 13 |
-
import { FilterButton, RoomCard } from "./RoomSetupComponents";
|
| 14 |
-
import { useRoomSetup } from "./roomSetupHooks";
|
| 15 |
-
import useAppStore from "../../store/useAppStore";
|
| 16 |
-
import { useHistoryStore, type HistoryItem } from "../../store/useAppStore";
|
| 17 |
-
import { useActiveSessions } from "../../hooks/useActiveSessions";
|
| 18 |
-
import { API_BASE } from "../../api/client";
|
| 19 |
-
import {
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
const
|
| 28 |
-
const
|
| 29 |
-
const
|
| 30 |
-
const
|
| 31 |
-
const
|
| 32 |
-
|
| 33 |
-
const
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
<
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
<span
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
<li
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
<li
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
<
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
}
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
>
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
</
|
| 205 |
-
<p className="text-[#707070] text-
|
| 206 |
-
|
| 207 |
-
</p>
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
<
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
}
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
{/* Cuadrícula de Imágenes */}
|
| 321 |
-
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 sm:gap-6">
|
| 322 |
-
{habitacionesFiltradas.map((hab) => (
|
| 323 |
-
<RoomCard
|
| 324 |
-
key={hab.id}
|
| 325 |
-
room={hab}
|
| 326 |
-
onSelect={handleDemoRoomSelect}
|
| 327 |
-
/>
|
| 328 |
-
))}
|
| 329 |
-
</div>
|
| 330 |
-
</section>
|
| 331 |
-
</main>
|
| 332 |
-
</div>
|
| 333 |
-
);
|
| 334 |
-
}
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import {
|
| 3 |
+
X,
|
| 4 |
+
Camera,
|
| 5 |
+
Package,
|
| 6 |
+
Camera as CameraIcon,
|
| 7 |
+
UploadCloud,
|
| 8 |
+
Users,
|
| 9 |
+
ArrowRight,
|
| 10 |
+
} from "lucide-react";
|
| 11 |
+
import { useNavigate } from "react-router-dom";
|
| 12 |
+
import { categorias, habitaciones } from "../../data/roomSetupData";
|
| 13 |
+
import { FilterButton, RoomCard } from "./RoomSetupComponents";
|
| 14 |
+
import { useRoomSetup } from "./roomSetupHooks";
|
| 15 |
+
import useAppStore from "../../store/useAppStore";
|
| 16 |
+
import { useHistoryStore, type HistoryItem } from "../../store/useAppStore";
|
| 17 |
+
import { useActiveSessions } from "../../hooks/useActiveSessions";
|
| 18 |
+
import { API_BASE } from "../../api/client";
|
| 19 |
+
import { useLoadSessionHistory, deleteSessionFromBackend } from "../../hooks/useSessionSync";
|
| 20 |
+
import { useCallback } from "react";
|
| 21 |
+
|
| 22 |
+
export default function RoomSetup() {
|
| 23 |
+
useLoadSessionHistory();
|
| 24 |
+
const [categoriaActiva, setCategoriaActiva] = useState("todos");
|
| 25 |
+
const navigate = useNavigate();
|
| 26 |
+
const segmentProgress = useAppStore((s) => s.segmentProgress);
|
| 27 |
+
const segmentFilename = useAppStore((s) => s.segmentFilename);
|
| 28 |
+
const storedPreviewImage = useAppStore((s) => s.previewImage);
|
| 29 |
+
const sessionHistory = useHistoryStore((s) => s.sessionHistory);
|
| 30 |
+
const removeFromHistory = useHistoryStore((s) => s.removeFromHistory);
|
| 31 |
+
const userId = useHistoryStore((s) => s.userId);
|
| 32 |
+
|
| 33 |
+
const handleDeleteSession = useCallback(
|
| 34 |
+
(e: React.MouseEvent, filename: string) => {
|
| 35 |
+
e.stopPropagation();
|
| 36 |
+
removeFromHistory(filename);
|
| 37 |
+
deleteSessionFromBackend(userId, filename);
|
| 38 |
+
},
|
| 39 |
+
[removeFromHistory, userId],
|
| 40 |
+
);
|
| 41 |
+
const { count: activeSessions } = useActiveSessions();
|
| 42 |
+
const {
|
| 43 |
+
isDragging,
|
| 44 |
+
previewImage,
|
| 45 |
+
uploadMessage,
|
| 46 |
+
isUploading,
|
| 47 |
+
fileInputRef,
|
| 48 |
+
handleDragOver,
|
| 49 |
+
handleDragLeave,
|
| 50 |
+
handleDrop,
|
| 51 |
+
handleFileChange,
|
| 52 |
+
handleDemoRoomSelect,
|
| 53 |
+
triggerFileInput,
|
| 54 |
+
clearPreviewImage,
|
| 55 |
+
} = useRoomSetup();
|
| 56 |
+
|
| 57 |
+
const habitacionesFiltradas =
|
| 58 |
+
categoriaActiva === "todos"
|
| 59 |
+
? habitaciones
|
| 60 |
+
: habitaciones.filter((h) => h.category === categoriaActiva);
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<div className="min-h-screen bg-[#f4f8ff] font-sans text-[#333333]">
|
| 64 |
+
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-8 relative">
|
| 65 |
+
{isUploading && (
|
| 66 |
+
<div className="fixed inset-0 bg-white/80 backdrop-blur-sm z-50 flex flex-col items-center justify-center gap-4 px-4">
|
| 67 |
+
<p className="text-[#0047AB] font-semibold text-lg text-center">
|
| 68 |
+
Analizando imagen con IA...
|
| 69 |
+
</p>
|
| 70 |
+
<div className="w-full max-w-xs sm:w-72 bg-gray-200 rounded-full h-3 overflow-hidden">
|
| 71 |
+
<div
|
| 72 |
+
className="bg-[#0047AB] h-3 rounded-full transition-all duration-500"
|
| 73 |
+
style={{ width: `${segmentProgress}%` }}
|
| 74 |
+
/>
|
| 75 |
+
</div>
|
| 76 |
+
<p className="text-sm text-gray-500">{uploadMessage}</p>
|
| 77 |
+
</div>
|
| 78 |
+
)}
|
| 79 |
+
|
| 80 |
+
{/* Badge de sesiones activas */}
|
| 81 |
+
{activeSessions > 0 && (
|
| 82 |
+
<div className="mb-4 flex justify-end">
|
| 83 |
+
<div className="flex items-center gap-2 bg-white border border-[#dbe7ff] rounded-full px-4 py-1.5 shadow-sm">
|
| 84 |
+
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
| 85 |
+
<Users className="w-4 h-4 text-[#0047AB]" />
|
| 86 |
+
<span className="text-sm font-medium text-[#0047AB]">
|
| 87 |
+
{activeSessions} usuario{activeSessions !== 1 ? "s" : ""} activo
|
| 88 |
+
{activeSessions !== 1 ? "s" : ""}
|
| 89 |
+
</span>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
)}
|
| 93 |
+
|
| 94 |
+
{/* SECCIÓN SUPERIOR: Sube tu foto */}
|
| 95 |
+
<section className="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-12 mb-10 sm:mb-16 lg:mb-20">
|
| 96 |
+
{/* Columna Izquierda: Textos y Botones */}
|
| 97 |
+
<div className="flex flex-col justify-center">
|
| 98 |
+
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-[#333333] mb-4 sm:mb-6 lg:mb-8">
|
| 99 |
+
Ver los productos en su cuarto
|
| 100 |
+
</h1>
|
| 101 |
+
|
| 102 |
+
<ul className="space-y-4 sm:space-y-6 mb-6 sm:mb-8 lg:mb-10">
|
| 103 |
+
<li className="flex items-center gap-3 text-base sm:text-lg font-medium text-[#333333]">
|
| 104 |
+
<Camera className="w-5 h-5 sm:w-6 sm:h-6 text-[#0047AB] shrink-0" />
|
| 105 |
+
Sube una foto de tu habitación
|
| 106 |
+
</li>
|
| 107 |
+
<li className="flex items-center gap-3 text-base sm:text-lg font-medium text-[#333333]">
|
| 108 |
+
<Package className="w-5 h-5 sm:w-6 sm:h-6 text-[#0047AB] shrink-0" />
|
| 109 |
+
Prueba nuestros productos
|
| 110 |
+
</li>
|
| 111 |
+
</ul>
|
| 112 |
+
|
| 113 |
+
<div className="w-full flex flex-col gap-3">
|
| 114 |
+
<button
|
| 115 |
+
onClick={triggerFileInput}
|
| 116 |
+
className="w-full bg-[#333333] hover:bg-black text-white font-bold text-base sm:text-lg py-3 sm:py-4 px-6 rounded-xl flex items-center justify-center gap-3 transition-colors shadow-sm"
|
| 117 |
+
>
|
| 118 |
+
<CameraIcon
|
| 119 |
+
className="w-5 h-5 sm:w-6 sm:h-6"
|
| 120 |
+
strokeWidth={2.5}
|
| 121 |
+
/>
|
| 122 |
+
Sube tu foto
|
| 123 |
+
</button>
|
| 124 |
+
|
| 125 |
+
{segmentFilename && !isUploading && (
|
| 126 |
+
<button
|
| 127 |
+
onClick={() =>
|
| 128 |
+
navigate("/visualizer", {
|
| 129 |
+
state: {
|
| 130 |
+
previewImage:
|
| 131 |
+
storedPreviewImage ??
|
| 132 |
+
`${API_BASE}/seg/image/${segmentFilename}`,
|
| 133 |
+
},
|
| 134 |
+
})
|
| 135 |
+
}
|
| 136 |
+
className="w-full bg-[#0047AB] hover:bg-[#003a94] text-white font-bold text-base sm:text-lg py-3 sm:py-4 px-6 rounded-xl flex items-center justify-center gap-3 transition-colors shadow-sm"
|
| 137 |
+
>
|
| 138 |
+
Continuar al visualizador
|
| 139 |
+
<ArrowRight
|
| 140 |
+
className="w-5 h-5 sm:w-6 sm:h-6"
|
| 141 |
+
strokeWidth={2.5}
|
| 142 |
+
/>
|
| 143 |
+
</button>
|
| 144 |
+
)}
|
| 145 |
+
</div>
|
| 146 |
+
{uploadMessage && (
|
| 147 |
+
<p className="text-sm text-[#707070] mt-3">{uploadMessage}</p>
|
| 148 |
+
)}
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
{/* Columna Derecha: Dropzone de Imagen */}
|
| 152 |
+
<div
|
| 153 |
+
className={`relative rounded-2xl overflow-hidden min-h-[220px] sm:aspect-[4/3] sm:min-h-0 flex flex-col items-center justify-center border-2 border-dashed transition-all duration-200 ${
|
| 154 |
+
isDragging
|
| 155 |
+
? "border-[#0047AB] bg-[#eaf1ff]"
|
| 156 |
+
: "border-[#dbe7ff] bg-[#f4f8ff] hover:bg-[#eef4ff]"
|
| 157 |
+
}`}
|
| 158 |
+
onDragOver={handleDragOver}
|
| 159 |
+
onDragLeave={handleDragLeave}
|
| 160 |
+
onDrop={handleDrop}
|
| 161 |
+
>
|
| 162 |
+
{previewImage ? (
|
| 163 |
+
<div className="relative w-full h-full group">
|
| 164 |
+
<img
|
| 165 |
+
src={previewImage}
|
| 166 |
+
alt="Vista previa"
|
| 167 |
+
className="w-full h-full object-cover"
|
| 168 |
+
/>
|
| 169 |
+
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center backdrop-blur-sm">
|
| 170 |
+
<button
|
| 171 |
+
onClick={clearPreviewImage}
|
| 172 |
+
className="bg-white text-[#0047AB] font-medium py-2 px-4 rounded-lg flex items-center gap-2 hover:bg-[#eef4ff] transition-colors shadow-lg"
|
| 173 |
+
>
|
| 174 |
+
<X className="w-4 h-4" /> Eliminar foto
|
| 175 |
+
</button>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
) : (
|
| 179 |
+
<>
|
| 180 |
+
<input
|
| 181 |
+
type="file"
|
| 182 |
+
accept="image/*"
|
| 183 |
+
onChange={handleFileChange}
|
| 184 |
+
ref={fileInputRef}
|
| 185 |
+
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
| 186 |
+
/>
|
| 187 |
+
<div className="flex flex-col items-center pointer-events-none text-center p-6">
|
| 188 |
+
<div
|
| 189 |
+
className={`w-14 h-14 sm:w-16 sm:h-16 rounded-full flex items-center justify-center mb-3 sm:mb-4 transition-colors ${
|
| 190 |
+
isDragging
|
| 191 |
+
? "bg-[#0047AB]/20 text-[#0047AB]"
|
| 192 |
+
: "bg-[#eaf1ff] text-[#0047AB]"
|
| 193 |
+
}`}
|
| 194 |
+
>
|
| 195 |
+
<UploadCloud className="w-7 h-7 sm:w-8 sm:h-8" />
|
| 196 |
+
</div>
|
| 197 |
+
<h3 className="text-lg sm:text-xl font-bold text-[#333333] mb-2">
|
| 198 |
+
{isDragging
|
| 199 |
+
? "Suelta la imagen aquí"
|
| 200 |
+
: "Arrastra tu foto aquí"}
|
| 201 |
+
</h3>
|
| 202 |
+
<p className="text-[#707070] text-sm sm:text-base">
|
| 203 |
+
o haz clic para explorar en tu dispositivo
|
| 204 |
+
</p>
|
| 205 |
+
<p className="text-[#707070] text-xs mt-3 sm:mt-4">
|
| 206 |
+
Formatos soportados: JPG, PNG
|
| 207 |
+
</p>
|
| 208 |
+
</div>
|
| 209 |
+
</>
|
| 210 |
+
)}
|
| 211 |
+
</div>
|
| 212 |
+
</section>
|
| 213 |
+
|
| 214 |
+
{/* SECCIÓN HISTORIAL DE SESIÓN */}
|
| 215 |
+
{sessionHistory.length > 0 && (
|
| 216 |
+
<section className="mb-10 sm:mb-14">
|
| 217 |
+
<h2 className="text-xl sm:text-2xl font-bold text-[#333333] mb-4 sm:mb-6">
|
| 218 |
+
Tus espacios recientes
|
| 219 |
+
</h2>
|
| 220 |
+
<div
|
| 221 |
+
className="flex gap-4 overflow-x-auto pb-2"
|
| 222 |
+
style={{ scrollbarWidth: "thin" }}
|
| 223 |
+
>
|
| 224 |
+
{sessionHistory.map((item: HistoryItem) => (
|
| 225 |
+
<button
|
| 226 |
+
key={item.filename}
|
| 227 |
+
onClick={() =>
|
| 228 |
+
navigate("/visualizer", {
|
| 229 |
+
state: {
|
| 230 |
+
previewImage: item.previewUrl,
|
| 231 |
+
filename: item.filename,
|
| 232 |
+
maskCount: item.maskCount,
|
| 233 |
+
},
|
| 234 |
+
})
|
| 235 |
+
}
|
| 236 |
+
className={`flex-shrink-0 group relative rounded-2xl overflow-hidden border-2 transition-all duration-200 ${
|
| 237 |
+
segmentFilename === item.filename
|
| 238 |
+
? "border-[#0047AB] shadow-lg shadow-[#0047AB]/20"
|
| 239 |
+
: "border-[#dbe7ff] hover:border-[#0047AB]"
|
| 240 |
+
}`}
|
| 241 |
+
style={{ width: 160, height: 120 }}
|
| 242 |
+
>
|
| 243 |
+
<img
|
| 244 |
+
src={item.previewUrl}
|
| 245 |
+
alt="Habitación previa"
|
| 246 |
+
className="w-full h-full object-cover"
|
| 247 |
+
/>
|
| 248 |
+
{/* Overlay con hora */}
|
| 249 |
+
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent flex flex-col justify-end p-2">
|
| 250 |
+
<p className="text-white/80 text-[10px]">
|
| 251 |
+
{new Date(item.uploadedAt).toLocaleDateString([], {
|
| 252 |
+
day: "2-digit",
|
| 253 |
+
month: "short",
|
| 254 |
+
})}{" "}
|
| 255 |
+
{new Date(item.uploadedAt).toLocaleTimeString([], {
|
| 256 |
+
hour: "2-digit",
|
| 257 |
+
minute: "2-digit",
|
| 258 |
+
})}
|
| 259 |
+
</p>
|
| 260 |
+
</div>
|
| 261 |
+
{/* Badge "activa" o botón eliminar */}
|
| 262 |
+
{segmentFilename === item.filename ? (
|
| 263 |
+
<div className="absolute top-2 right-2 bg-[#0047AB] text-white text-[9px] font-bold px-1.5 py-0.5 rounded-full">
|
| 264 |
+
Activa
|
| 265 |
+
</div>
|
| 266 |
+
) : (
|
| 267 |
+
<button
|
| 268 |
+
onClick={(e) => handleDeleteSession(e, item.filename)}
|
| 269 |
+
className="absolute top-2 right-2 w-5 h-5 rounded-full bg-black/50 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500"
|
| 270 |
+
>
|
| 271 |
+
<X className="w-3 h-3" />
|
| 272 |
+
</button>
|
| 273 |
+
)}
|
| 274 |
+
{/* Hover: continuar */}
|
| 275 |
+
<div className="absolute inset-0 bg-[#0047AB]/80 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
| 276 |
+
<span className="text-white text-xs font-bold flex items-center gap-1">
|
| 277 |
+
Continuar <ArrowRight className="w-3.5 h-3.5" />
|
| 278 |
+
</span>
|
| 279 |
+
</div>
|
| 280 |
+
</button>
|
| 281 |
+
))}
|
| 282 |
+
</div>
|
| 283 |
+
</section>
|
| 284 |
+
)}
|
| 285 |
+
|
| 286 |
+
{/* SECCIÓN INFERIOR: Habitaciones de demostración */}
|
| 287 |
+
<section>
|
| 288 |
+
<h2 className="text-xl sm:text-2xl font-bold text-[#0047AB] mb-4 sm:mb-6">
|
| 289 |
+
¿No tienes una foto? Prueba nuestras habitaciones de demostración
|
| 290 |
+
</h2>
|
| 291 |
+
|
| 292 |
+
{/* Filtros */}
|
| 293 |
+
<div className="flex flex-wrap gap-2 mb-6 sm:mb-8">
|
| 294 |
+
{categorias.map((cat) => (
|
| 295 |
+
<FilterButton
|
| 296 |
+
key={cat.id}
|
| 297 |
+
category={cat}
|
| 298 |
+
active={categoriaActiva === cat.id}
|
| 299 |
+
onSelect={setCategoriaActiva}
|
| 300 |
+
/>
|
| 301 |
+
))}
|
| 302 |
+
</div>
|
| 303 |
+
|
| 304 |
+
{/* Cuadrícula de Imágenes */}
|
| 305 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 sm:gap-6">
|
| 306 |
+
{habitacionesFiltradas.map((hab) => (
|
| 307 |
+
<RoomCard
|
| 308 |
+
key={hab.id}
|
| 309 |
+
room={hab}
|
| 310 |
+
onSelect={handleDemoRoomSelect}
|
| 311 |
+
/>
|
| 312 |
+
))}
|
| 313 |
+
</div>
|
| 314 |
+
</section>
|
| 315 |
+
</main>
|
| 316 |
+
</div>
|
| 317 |
+
);
|
| 318 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/features/roomSetup/RoomSetupComponents.tsx
CHANGED
|
@@ -1,62 +1,62 @@
|
|
| 1 |
-
import {
|
| 2 |
-
type RoomCategory,
|
| 3 |
-
type RoomItem,
|
| 4 |
-
categorias,
|
| 5 |
-
} from "../../data/roomSetupData";
|
| 6 |
-
|
| 7 |
-
type FilterButtonProps = {
|
| 8 |
-
category: RoomCategory;
|
| 9 |
-
active: boolean;
|
| 10 |
-
onSelect: (id: string) => void;
|
| 11 |
-
};
|
| 12 |
-
|
| 13 |
-
export function FilterButton({
|
| 14 |
-
category,
|
| 15 |
-
active,
|
| 16 |
-
onSelect,
|
| 17 |
-
}: FilterButtonProps) {
|
| 18 |
-
return (
|
| 19 |
-
<button
|
| 20 |
-
type="button"
|
| 21 |
-
onClick={() => onSelect(category.id)}
|
| 22 |
-
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 border ${
|
| 23 |
-
active
|
| 24 |
-
? "bg-[#0047AB] border-[#0047AB] text-white shadow-sm"
|
| 25 |
-
: "bg-white border-[#0047AB] text-[#0047AB] hover:bg-[#eaf1ff]"
|
| 26 |
-
}`}
|
| 27 |
-
>
|
| 28 |
-
{category.label} ({category.count})
|
| 29 |
-
</button>
|
| 30 |
-
);
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
type RoomCardProps = {
|
| 34 |
-
room: RoomItem;
|
| 35 |
-
onSelect: (url: string) => void;
|
| 36 |
-
};
|
| 37 |
-
|
| 38 |
-
export function RoomCard({ room, onSelect }: RoomCardProps) {
|
| 39 |
-
const categoryLabel =
|
| 40 |
-
categorias.find((cat) => cat.id === room.category)?.label ?? room.category;
|
| 41 |
-
|
| 42 |
-
return (
|
| 43 |
-
<button
|
| 44 |
-
type="button"
|
| 45 |
-
onClick={() => onSelect(room.img)}
|
| 46 |
-
className="group cursor-pointer text-left"
|
| 47 |
-
>
|
| 48 |
-
<div className="relative aspect-[4/3] rounded-2xl overflow-hidden mb-3 bg-[#edf4ff]">
|
| 49 |
-
<img
|
| 50 |
-
src={room.img}
|
| 51 |
-
alt={room.title}
|
| 52 |
-
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
| 53 |
-
/>
|
| 54 |
-
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-300" />
|
| 55 |
-
</div>
|
| 56 |
-
<div className="px-1">
|
| 57 |
-
<h3 className="text-[#333333] font-medium text-sm">{room.title}</h3>
|
| 58 |
-
<p className="text-[#707070] text-xs mt-1">{categoryLabel}</p>
|
| 59 |
-
</div>
|
| 60 |
-
</button>
|
| 61 |
-
);
|
| 62 |
-
}
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
type RoomCategory,
|
| 3 |
+
type RoomItem,
|
| 4 |
+
categorias,
|
| 5 |
+
} from "../../data/roomSetupData";
|
| 6 |
+
|
| 7 |
+
type FilterButtonProps = {
|
| 8 |
+
category: RoomCategory;
|
| 9 |
+
active: boolean;
|
| 10 |
+
onSelect: (id: string) => void;
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
export function FilterButton({
|
| 14 |
+
category,
|
| 15 |
+
active,
|
| 16 |
+
onSelect,
|
| 17 |
+
}: FilterButtonProps) {
|
| 18 |
+
return (
|
| 19 |
+
<button
|
| 20 |
+
type="button"
|
| 21 |
+
onClick={() => onSelect(category.id)}
|
| 22 |
+
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 border ${
|
| 23 |
+
active
|
| 24 |
+
? "bg-[#0047AB] border-[#0047AB] text-white shadow-sm"
|
| 25 |
+
: "bg-white border-[#0047AB] text-[#0047AB] hover:bg-[#eaf1ff]"
|
| 26 |
+
}`}
|
| 27 |
+
>
|
| 28 |
+
{category.label} ({category.count})
|
| 29 |
+
</button>
|
| 30 |
+
);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
type RoomCardProps = {
|
| 34 |
+
room: RoomItem;
|
| 35 |
+
onSelect: (url: string) => void;
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
export function RoomCard({ room, onSelect }: RoomCardProps) {
|
| 39 |
+
const categoryLabel =
|
| 40 |
+
categorias.find((cat) => cat.id === room.category)?.label ?? room.category;
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
<button
|
| 44 |
+
type="button"
|
| 45 |
+
onClick={() => onSelect(room.img)}
|
| 46 |
+
className="group cursor-pointer text-left"
|
| 47 |
+
>
|
| 48 |
+
<div className="relative aspect-[4/3] rounded-2xl overflow-hidden mb-3 bg-[#edf4ff]">
|
| 49 |
+
<img
|
| 50 |
+
src={room.img}
|
| 51 |
+
alt={room.title}
|
| 52 |
+
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
| 53 |
+
/>
|
| 54 |
+
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-300" />
|
| 55 |
+
</div>
|
| 56 |
+
<div className="px-1">
|
| 57 |
+
<h3 className="text-[#333333] font-medium text-sm">{room.title}</h3>
|
| 58 |
+
<p className="text-[#707070] text-xs mt-1">{categoryLabel}</p>
|
| 59 |
+
</div>
|
| 60 |
+
</button>
|
| 61 |
+
);
|
| 62 |
+
}
|
frontend/src/features/roomSetup/roomSetup.types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
// Tipos específicos de la feature roomSetup.
|
| 2 |
-
|
| 3 |
-
export type RoomSetupState = {
|
| 4 |
-
previewImage: string | null;
|
| 5 |
-
uploadMessage: string | null;
|
| 6 |
-
};
|
|
|
|
| 1 |
+
// Tipos específicos de la feature roomSetup.
|
| 2 |
+
|
| 3 |
+
export type RoomSetupState = {
|
| 4 |
+
previewImage: string | null;
|
| 5 |
+
uploadMessage: string | null;
|
| 6 |
+
};
|
frontend/src/features/roomSetup/roomSetupHooks.ts
CHANGED
|
@@ -7,8 +7,11 @@ import {
|
|
| 7 |
type RefObject,
|
| 8 |
} from "react";
|
| 9 |
import { useNavigate } from "react-router-dom";
|
| 10 |
-
import useAppStore
|
| 11 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
export function useRoomSetup(): {
|
| 14 |
isDragging: boolean;
|
|
@@ -37,9 +40,7 @@ export function useRoomSetup(): {
|
|
| 37 |
const addToHistory = useHistoryStore((state) => state.addToHistory);
|
| 38 |
const userId = useHistoryStore((state) => state.userId);
|
| 39 |
|
| 40 |
-
const
|
| 41 |
-
// applyTextureOpenAI is unused in this hook; call it from the visualizer where needed
|
| 42 |
-
const isUploading = false;
|
| 43 |
|
| 44 |
const clearPreviewImage = useCallback(() => {
|
| 45 |
setPreviewImage(null);
|
|
@@ -52,30 +53,35 @@ export function useRoomSetup(): {
|
|
| 52 |
// Blob URL used only for local preview while uploading; replaced with server URL after
|
| 53 |
const objectUrl = URL.createObjectURL(file);
|
| 54 |
setPreviewImage(objectUrl);
|
| 55 |
-
setUploadMessage("
|
| 56 |
-
setUploadedFile(file);
|
| 57 |
-
|
| 58 |
-
// Start uploading in background so backend has a stable URL for processing.
|
| 59 |
-
(async () => {
|
| 60 |
-
try {
|
| 61 |
-
const res = await uploadRoomImage(file);
|
| 62 |
-
if (res?.url) {
|
| 63 |
-
const serverUrl = buildApiUrl(res.url as string);
|
| 64 |
-
setPreviewImage(serverUrl);
|
| 65 |
-
setUploadMessage("Imagen subida al servidor");
|
| 66 |
-
}
|
| 67 |
-
} catch (err) {
|
| 68 |
-
const msg =
|
| 69 |
-
err instanceof Error ? err.message : "Error al subir la imagen";
|
| 70 |
-
setUploadMessage(msg);
|
| 71 |
-
}
|
| 72 |
-
})();
|
| 73 |
|
| 74 |
try {
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
navigate("/visualizer", {
|
| 78 |
-
state: { previewImage:
|
| 79 |
});
|
| 80 |
} catch (err) {
|
| 81 |
const message =
|
|
@@ -89,6 +95,7 @@ export function useRoomSetup(): {
|
|
| 89 |
setUploadMessage,
|
| 90 |
setSegmentResult,
|
| 91 |
setSegmentProgress,
|
|
|
|
| 92 |
addToHistory,
|
| 93 |
userId,
|
| 94 |
],
|
|
|
|
| 7 |
type RefObject,
|
| 8 |
} from "react";
|
| 9 |
import { useNavigate } from "react-router-dom";
|
| 10 |
+
import useAppStore from "../../store/useAppStore";
|
| 11 |
+
import { useHistoryStore } from "../../store/useAppStore";
|
| 12 |
+
import { useSegmentUpload } from "../../hooks/useSegmentUpload";
|
| 13 |
+
import { API_BASE } from "../../api/client";
|
| 14 |
+
import { saveSessionToBackend } from "../../hooks/useSessionSync";
|
| 15 |
|
| 16 |
export function useRoomSetup(): {
|
| 17 |
isDragging: boolean;
|
|
|
|
| 40 |
const addToHistory = useHistoryStore((state) => state.addToHistory);
|
| 41 |
const userId = useHistoryStore((state) => state.userId);
|
| 42 |
|
| 43 |
+
const { uploadAndSegment, isUploading } = useSegmentUpload();
|
|
|
|
|
|
|
| 44 |
|
| 45 |
const clearPreviewImage = useCallback(() => {
|
| 46 |
setPreviewImage(null);
|
|
|
|
| 53 |
// Blob URL used only for local preview while uploading; replaced with server URL after
|
| 54 |
const objectUrl = URL.createObjectURL(file);
|
| 55 |
setPreviewImage(objectUrl);
|
| 56 |
+
setUploadMessage("Iniciando segmentación...");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
try {
|
| 59 |
+
const { filename, maskCount } = await uploadAndSegment(
|
| 60 |
+
file,
|
| 61 |
+
(progress, message) => {
|
| 62 |
+
setSegmentProgress(progress);
|
| 63 |
+
setUploadMessage(message);
|
| 64 |
+
},
|
| 65 |
+
);
|
| 66 |
+
|
| 67 |
+
setSegmentResult(filename, maskCount);
|
| 68 |
+
setUploadMessage(`Segmentación completa — ${maskCount} zonas detectadas`);
|
| 69 |
+
|
| 70 |
+
// Server URL persists across page refreshes; blob URL does not
|
| 71 |
+
const serverImageUrl = `${API_BASE}/seg/image/${filename}`;
|
| 72 |
+
setPreviewImage(serverImageUrl);
|
| 73 |
+
|
| 74 |
+
const historyItem = {
|
| 75 |
+
filename,
|
| 76 |
+
previewUrl: serverImageUrl,
|
| 77 |
+
maskCount,
|
| 78 |
+
uploadedAt: Date.now(),
|
| 79 |
+
};
|
| 80 |
+
addToHistory(historyItem);
|
| 81 |
+
saveSessionToBackend(userId, historyItem);
|
| 82 |
+
|
| 83 |
navigate("/visualizer", {
|
| 84 |
+
state: { previewImage: serverImageUrl },
|
| 85 |
});
|
| 86 |
} catch (err) {
|
| 87 |
const message =
|
|
|
|
| 95 |
setUploadMessage,
|
| 96 |
setSegmentResult,
|
| 97 |
setSegmentProgress,
|
| 98 |
+
uploadAndSegment,
|
| 99 |
addToHistory,
|
| 100 |
userId,
|
| 101 |
],
|
frontend/src/features/roomVisualizer/MaskLayer.tsx
CHANGED
|
@@ -1,45 +1,45 @@
|
|
| 1 |
-
import React from "react";
|
| 2 |
-
|
| 3 |
-
interface MaskLayerProps {
|
| 4 |
-
maskUrl: string;
|
| 5 |
-
color: string;
|
| 6 |
-
onMouseEnter: (maskUrl: string) => void;
|
| 7 |
-
onClick: (maskUrl: string) => void;
|
| 8 |
-
}
|
| 9 |
-
|
| 10 |
-
/**
|
| 11 |
-
* Componente para renderizar una única capa de máscara.
|
| 12 |
-
* Usa React.memo para evitar re-renderizados si las props no cambian.
|
| 13 |
-
*/
|
| 14 |
-
const MaskLayer = React.memo(function MaskLayer({
|
| 15 |
-
maskUrl,
|
| 16 |
-
color,
|
| 17 |
-
onMouseEnter,
|
| 18 |
-
onClick,
|
| 19 |
-
}: MaskLayerProps) {
|
| 20 |
-
// Creamos los manejadores de eventos aquí para pasar la URL de la máscara.
|
| 21 |
-
// Como el componente está memoizado, estas funciones solo se recrean si las props cambian.
|
| 22 |
-
const handleMouseEnter = () => onMouseEnter(maskUrl);
|
| 23 |
-
const handleClick = () => onClick(maskUrl);
|
| 24 |
-
|
| 25 |
-
return (
|
| 26 |
-
<div
|
| 27 |
-
className="absolute top-0 left-0 w-full h-full transition-opacity duration-200"
|
| 28 |
-
style={{
|
| 29 |
-
backgroundColor: color,
|
| 30 |
-
maskImage: `url(${maskUrl})`,
|
| 31 |
-
WebkitMaskImage: `url(${maskUrl})`,
|
| 32 |
-
maskSize: "contain",
|
| 33 |
-
WebkitMaskSize: "contain",
|
| 34 |
-
maskRepeat: "no-repeat",
|
| 35 |
-
WebkitMaskRepeat: "no-repeat",
|
| 36 |
-
maskPosition: "center",
|
| 37 |
-
WebkitMaskPosition: "center",
|
| 38 |
-
}}
|
| 39 |
-
onMouseEnter={handleMouseEnter}
|
| 40 |
-
onClick={handleClick}
|
| 41 |
-
/>
|
| 42 |
-
);
|
| 43 |
-
});
|
| 44 |
-
|
| 45 |
-
export default MaskLayer;
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
|
| 3 |
+
interface MaskLayerProps {
|
| 4 |
+
maskUrl: string;
|
| 5 |
+
color: string;
|
| 6 |
+
onMouseEnter: (maskUrl: string) => void;
|
| 7 |
+
onClick: (maskUrl: string) => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Componente para renderizar una única capa de máscara.
|
| 12 |
+
* Usa React.memo para evitar re-renderizados si las props no cambian.
|
| 13 |
+
*/
|
| 14 |
+
const MaskLayer = React.memo(function MaskLayer({
|
| 15 |
+
maskUrl,
|
| 16 |
+
color,
|
| 17 |
+
onMouseEnter,
|
| 18 |
+
onClick,
|
| 19 |
+
}: MaskLayerProps) {
|
| 20 |
+
// Creamos los manejadores de eventos aquí para pasar la URL de la máscara.
|
| 21 |
+
// Como el componente está memoizado, estas funciones solo se recrean si las props cambian.
|
| 22 |
+
const handleMouseEnter = () => onMouseEnter(maskUrl);
|
| 23 |
+
const handleClick = () => onClick(maskUrl);
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<div
|
| 27 |
+
className="absolute top-0 left-0 w-full h-full transition-opacity duration-200"
|
| 28 |
+
style={{
|
| 29 |
+
backgroundColor: color,
|
| 30 |
+
maskImage: `url(${maskUrl})`,
|
| 31 |
+
WebkitMaskImage: `url(${maskUrl})`,
|
| 32 |
+
maskSize: "contain",
|
| 33 |
+
WebkitMaskSize: "contain",
|
| 34 |
+
maskRepeat: "no-repeat",
|
| 35 |
+
WebkitMaskRepeat: "no-repeat",
|
| 36 |
+
maskPosition: "center",
|
| 37 |
+
WebkitMaskPosition: "center",
|
| 38 |
+
}}
|
| 39 |
+
onMouseEnter={handleMouseEnter}
|
| 40 |
+
onClick={handleClick}
|
| 41 |
+
/>
|
| 42 |
+
);
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
export default MaskLayer;
|
frontend/src/features/roomVisualizer/RoomPreviewPanel.tsx
CHANGED
|
@@ -1,338 +1,329 @@
|
|
| 1 |
-
import {
|
| 2 |
-
type PointerEvent,
|
| 3 |
-
type SyntheticEvent,
|
| 4 |
-
type RefObject,
|
| 5 |
-
} from "react";
|
| 6 |
-
import {
|
| 7 |
-
ArrowLeft,
|
| 8 |
-
Share2,
|
| 9 |
-
Download,
|
| 10 |
-
MapPin,
|
| 11 |
-
ShoppingCart,
|
| 12 |
-
RefreshCw,
|
| 13 |
-
RotateCcw,
|
| 14 |
-
Paintbrush,
|
| 15 |
-
Loader2,
|
| 16 |
-
} from "lucide-react";
|
| 17 |
-
import type { Product } from "../../types";
|
| 18 |
-
import type { SegmentMeta } from "../../hooks/useSegmentCanvas";
|
| 19 |
-
|
| 20 |
-
interface RoomPreviewPanelProps {
|
| 21 |
-
previewImage?: string | null;
|
| 22 |
-
offset: { x: number; y: number };
|
| 23 |
-
zoom: number;
|
| 24 |
-
imageSize: { width: number; height: number };
|
| 25 |
-
wrapperRef: RefObject<HTMLDivElement | null>;
|
| 26 |
-
canvasRef: RefObject<HTMLCanvasElement | null>;
|
| 27 |
-
selectedProduct: Product | null;
|
| 28 |
-
selectedMasks: Set<number>;
|
| 29 |
-
hoveredMask: number;
|
| 30 |
-
segmentMeta: Map<number, SegmentMeta>;
|
| 31 |
-
isApplying: boolean;
|
| 32 |
-
onBack: () => void;
|
| 33 |
-
onPointerDown: (event: PointerEvent<HTMLDivElement>) => void;
|
| 34 |
-
onPointerMove: (event: PointerEvent<HTMLDivElement>) => void;
|
| 35 |
-
onPointerUp: (event: PointerEvent<HTMLDivElement>) => void;
|
| 36 |
-
updateImageSize: (img: HTMLImageElement) => void;
|
| 37 |
-
onCanvasMouseMove: (e: React.MouseEvent<HTMLCanvasElement>) => void;
|
| 38 |
-
onCanvasMouseLeave: () => void;
|
| 39 |
-
onCanvasClick: (e: React.MouseEvent<HTMLCanvasElement>) => void;
|
| 40 |
-
onApplyTexture: () => void;
|
| 41 |
-
onReset: () => void;
|
| 42 |
-
onDownload: () => Promise<void>;
|
| 43 |
-
onShare: () => Promise<void>;
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
</div>
|
| 210 |
-
)
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
<
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
>
|
| 272 |
-
<
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
{
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
</
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
<button
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
</span>
|
| 331 |
-
Girar
|
| 332 |
-
</button>
|
| 333 |
-
</div>
|
| 334 |
-
|
| 335 |
-
</div>
|
| 336 |
-
</div>
|
| 337 |
-
);
|
| 338 |
-
}
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
type PointerEvent,
|
| 3 |
+
type SyntheticEvent,
|
| 4 |
+
type RefObject,
|
| 5 |
+
} from "react";
|
| 6 |
+
import {
|
| 7 |
+
ArrowLeft,
|
| 8 |
+
Share2,
|
| 9 |
+
Download,
|
| 10 |
+
MapPin,
|
| 11 |
+
ShoppingCart,
|
| 12 |
+
RefreshCw,
|
| 13 |
+
RotateCcw,
|
| 14 |
+
Paintbrush,
|
| 15 |
+
Loader2,
|
| 16 |
+
} from "lucide-react";
|
| 17 |
+
import type { Product } from "../../types";
|
| 18 |
+
import type { SegmentMeta } from "../../hooks/useSegmentCanvas";
|
| 19 |
+
|
| 20 |
+
interface RoomPreviewPanelProps {
|
| 21 |
+
previewImage?: string | null;
|
| 22 |
+
offset: { x: number; y: number };
|
| 23 |
+
zoom: number;
|
| 24 |
+
imageSize: { width: number; height: number };
|
| 25 |
+
wrapperRef: RefObject<HTMLDivElement | null>;
|
| 26 |
+
canvasRef: RefObject<HTMLCanvasElement | null>;
|
| 27 |
+
selectedProduct: Product | null;
|
| 28 |
+
selectedMasks: Set<number>;
|
| 29 |
+
hoveredMask: number;
|
| 30 |
+
segmentMeta: Map<number, SegmentMeta>;
|
| 31 |
+
isApplying: boolean;
|
| 32 |
+
onBack: () => void;
|
| 33 |
+
onPointerDown: (event: PointerEvent<HTMLDivElement>) => void;
|
| 34 |
+
onPointerMove: (event: PointerEvent<HTMLDivElement>) => void;
|
| 35 |
+
onPointerUp: (event: PointerEvent<HTMLDivElement>) => void;
|
| 36 |
+
updateImageSize: (img: HTMLImageElement) => void;
|
| 37 |
+
onCanvasMouseMove: (e: React.MouseEvent<HTMLCanvasElement>) => void;
|
| 38 |
+
onCanvasMouseLeave: () => void;
|
| 39 |
+
onCanvasClick: (e: React.MouseEvent<HTMLCanvasElement>) => void;
|
| 40 |
+
onApplyTexture: () => void;
|
| 41 |
+
onReset: () => void;
|
| 42 |
+
onDownload: () => Promise<void>;
|
| 43 |
+
onShare: () => Promise<void>;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export function RoomPreviewPanel({
|
| 47 |
+
previewImage,
|
| 48 |
+
offset,
|
| 49 |
+
zoom,
|
| 50 |
+
imageSize,
|
| 51 |
+
wrapperRef,
|
| 52 |
+
canvasRef,
|
| 53 |
+
selectedProduct,
|
| 54 |
+
selectedMasks,
|
| 55 |
+
hoveredMask,
|
| 56 |
+
segmentMeta,
|
| 57 |
+
isApplying,
|
| 58 |
+
onBack,
|
| 59 |
+
onPointerDown,
|
| 60 |
+
onPointerMove,
|
| 61 |
+
onPointerUp,
|
| 62 |
+
updateImageSize,
|
| 63 |
+
onCanvasMouseMove,
|
| 64 |
+
onCanvasMouseLeave,
|
| 65 |
+
onCanvasClick,
|
| 66 |
+
onApplyTexture,
|
| 67 |
+
onReset,
|
| 68 |
+
onDownload,
|
| 69 |
+
onShare,
|
| 70 |
+
}: RoomPreviewPanelProps) {
|
| 71 |
+
const canApply = selectedMasks.size > 0 && selectedProduct != null;
|
| 72 |
+
|
| 73 |
+
const getLabel = (index: number) =>
|
| 74 |
+
segmentMeta.get(index)?.label ?? `Zona ${index}`;
|
| 75 |
+
|
| 76 |
+
return (
|
| 77 |
+
<div className="w-full h-full bg-white overflow-hidden">
|
| 78 |
+
<div className="relative h-full overflow-hidden lg:rounded-lg bg-[#f4f8ff]">
|
| 79 |
+
|
| 80 |
+
{/* ── Mobile top bar ───────────────────────────────────────── */}
|
| 81 |
+
{/* pr-12 reserva espacio a la derecha para el botón .hr-close del padre */}
|
| 82 |
+
<div className="lg:hidden absolute left-0 right-0 top-0 z-10 h-12 bg-white shadow-sm flex items-center justify-between px-3 pr-12">
|
| 83 |
+
<button
|
| 84 |
+
onClick={onBack}
|
| 85 |
+
className="p-2 rounded-full hover:bg-gray-100 transition-colors"
|
| 86 |
+
>
|
| 87 |
+
<ArrowLeft className="h-5 w-5 text-[#333]" />
|
| 88 |
+
</button>
|
| 89 |
+
<div className="flex items-center gap-1">
|
| 90 |
+
<button
|
| 91 |
+
onClick={onShare}
|
| 92 |
+
className="p-2 rounded-full hover:bg-[#eaf1ff] transition-colors"
|
| 93 |
+
>
|
| 94 |
+
<Share2 className="h-5 w-5 text-[#0047AB]" />
|
| 95 |
+
</button>
|
| 96 |
+
<button
|
| 97 |
+
onClick={onDownload}
|
| 98 |
+
className="p-2 rounded-full hover:bg-[#eaf1ff] transition-colors"
|
| 99 |
+
>
|
| 100 |
+
<Download className="h-5 w-5 text-[#0047AB]" />
|
| 101 |
+
</button>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
|
| 105 |
+
{/* ── Desktop top bar ──────────────────────────────────────── */}
|
| 106 |
+
<div className="pointer-events-none hidden lg:flex absolute left-1/2 top-0 z-10 -translate-x-1/2 w-[80%] h-16 rounded-b-md bg-white shadow-sm items-center justify-center gap-4 px-3">
|
| 107 |
+
<button
|
| 108 |
+
onClick={onBack}
|
| 109 |
+
className="pointer-events-auto rounded-full bg-[#333333] px-4 py-2 text-sm font-semibold text-white hover:bg-black flex items-center gap-2"
|
| 110 |
+
>
|
| 111 |
+
<ArrowLeft className="h-4 w-4" /> Cambiar de Habitación
|
| 112 |
+
</button>
|
| 113 |
+
<span className="text-gray-400">|</span>
|
| 114 |
+
<button
|
| 115 |
+
onClick={onShare}
|
| 116 |
+
className="pointer-events-auto rounded-full bg-transparent px-4 py-2 text-sm font-medium text-[#0047AB] hover:bg-[#eaf1ff] flex items-center gap-2"
|
| 117 |
+
>
|
| 118 |
+
<Share2 className="h-4 w-4 text-[#0047AB]" />
|
| 119 |
+
Compartir
|
| 120 |
+
</button>
|
| 121 |
+
<button
|
| 122 |
+
onClick={onDownload}
|
| 123 |
+
className="pointer-events-auto rounded-full bg-transparent px-4 py-2 text-sm font-medium text-[#0047AB] hover:bg-[#eaf1ff] flex items-center gap-2"
|
| 124 |
+
>
|
| 125 |
+
<Download className="h-4 w-4 text-[#0047AB]" /> Descargar
|
| 126 |
+
</button>
|
| 127 |
+
<a
|
| 128 |
+
href="https://nauffargermany.com/gt/sucursales-2/"
|
| 129 |
+
target="_blank"
|
| 130 |
+
rel="noopener noreferrer"
|
| 131 |
+
className="pointer-events-auto rounded-full bg-transparent px-4 py-2 text-sm font-medium text-[#0047AB] hover:bg-[#eaf1ff] flex items-center gap-2"
|
| 132 |
+
>
|
| 133 |
+
<MapPin className="h-4 w-4 text-[#0047AB]" /> Encuentra tu tienda
|
| 134 |
+
</a>
|
| 135 |
+
{selectedProduct?.detailUrl ? (
|
| 136 |
+
<a
|
| 137 |
+
href={selectedProduct.detailUrl}
|
| 138 |
+
target="_blank"
|
| 139 |
+
rel="noopener noreferrer"
|
| 140 |
+
className="pointer-events-auto rounded-full bg-transparent px-4 py-2 text-sm font-medium text-[#0047AB] hover:bg-[#eaf1ff] flex items-center gap-2"
|
| 141 |
+
>
|
| 142 |
+
<ShoppingCart className="h-4 w-4 text-[#0047AB]" /> Ir a la página del producto
|
| 143 |
+
</a>
|
| 144 |
+
) : (
|
| 145 |
+
<button
|
| 146 |
+
disabled
|
| 147 |
+
className="pointer-events-auto rounded-full bg-transparent px-4 py-2 text-sm font-medium text-gray-300 flex items-center gap-2 cursor-default"
|
| 148 |
+
>
|
| 149 |
+
<ShoppingCart className="h-4 w-4 text-gray-300" /> Ir a la página del producto
|
| 150 |
+
</button>
|
| 151 |
+
)}
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
{/* ── Área de imagen + canvas ──────────────────────────────── */}
|
| 155 |
+
<div
|
| 156 |
+
ref={wrapperRef}
|
| 157 |
+
className="absolute inset-x-0 top-12 lg:top-16 bottom-12 lg:bottom-16 flex items-center justify-center bg-[#edf4ff] overflow-hidden"
|
| 158 |
+
onPointerDown={onPointerDown}
|
| 159 |
+
onPointerMove={onPointerMove}
|
| 160 |
+
onPointerUp={onPointerUp}
|
| 161 |
+
>
|
| 162 |
+
{previewImage ? (
|
| 163 |
+
<div
|
| 164 |
+
style={{
|
| 165 |
+
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
|
| 166 |
+
transformOrigin: "center center",
|
| 167 |
+
position: "relative",
|
| 168 |
+
display: "inline-flex",
|
| 169 |
+
lineHeight: 0,
|
| 170 |
+
...(imageSize.width > 0
|
| 171 |
+
? { width: imageSize.width, height: imageSize.height }
|
| 172 |
+
: {}),
|
| 173 |
+
}}
|
| 174 |
+
>
|
| 175 |
+
<img
|
| 176 |
+
src={previewImage}
|
| 177 |
+
alt="Vista previa de la habitación"
|
| 178 |
+
draggable={false}
|
| 179 |
+
onDragStart={(e) => e.preventDefault()}
|
| 180 |
+
onLoad={(event: SyntheticEvent<HTMLImageElement>) =>
|
| 181 |
+
updateImageSize(event.currentTarget)
|
| 182 |
+
}
|
| 183 |
+
style={{
|
| 184 |
+
display: "block",
|
| 185 |
+
width: imageSize.width > 0 ? "100%" : "auto",
|
| 186 |
+
height: imageSize.height > 0 ? "100%" : "auto",
|
| 187 |
+
maxWidth: imageSize.width > 0 ? "none" : "100%",
|
| 188 |
+
maxHeight: imageSize.height > 0 ? "none" : "100%",
|
| 189 |
+
objectFit: "contain",
|
| 190 |
+
}}
|
| 191 |
+
/>
|
| 192 |
+
<canvas
|
| 193 |
+
ref={canvasRef}
|
| 194 |
+
style={{
|
| 195 |
+
position: "absolute",
|
| 196 |
+
inset: 0,
|
| 197 |
+
width: "100%",
|
| 198 |
+
height: "100%",
|
| 199 |
+
cursor: "crosshair",
|
| 200 |
+
}}
|
| 201 |
+
onMouseMove={onCanvasMouseMove}
|
| 202 |
+
onMouseLeave={onCanvasMouseLeave}
|
| 203 |
+
onClick={onCanvasClick}
|
| 204 |
+
/>
|
| 205 |
+
</div>
|
| 206 |
+
) : (
|
| 207 |
+
<div className="flex h-full w-full items-center justify-center text-[#707070] text-sm px-6 text-center">
|
| 208 |
+
No hay vista previa disponible aún.
|
| 209 |
+
</div>
|
| 210 |
+
)}
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
{/* ── Hint de selección ────────────────────────────────────── */}
|
| 214 |
+
{previewImage && selectedMasks.size === 0 && (
|
| 215 |
+
<div className="pointer-events-none absolute top-14 lg:top-20 left-1/2 -translate-x-1/2 z-10 bg-black/50 text-white text-xs px-3 py-1.5 rounded-full whitespace-nowrap">
|
| 216 |
+
{hoveredMask > 0
|
| 217 |
+
? `${getLabel(hoveredMask)} — haz clic para seleccionar`
|
| 218 |
+
: "Haz clic sobre una zona de la imagen para seleccionarla"}
|
| 219 |
+
</div>
|
| 220 |
+
)}
|
| 221 |
+
|
| 222 |
+
{/* ── Mobile bottom bar ────────────────────────────────────── */}
|
| 223 |
+
<div className="pointer-events-none lg:hidden absolute left-0 right-0 bottom-0 z-10 h-12 bg-white border-t border-gray-100 shadow-sm flex items-center px-3 gap-2">
|
| 224 |
+
{selectedProduct && (
|
| 225 |
+
<div className="flex items-center gap-2 min-w-0 flex-1">
|
| 226 |
+
<img
|
| 227 |
+
src={selectedProduct.image}
|
| 228 |
+
alt={selectedProduct.name}
|
| 229 |
+
className="w-8 h-8 object-cover rounded-md border border-gray-200 shrink-0"
|
| 230 |
+
/>
|
| 231 |
+
<div className="min-w-0">
|
| 232 |
+
<p className="text-[10px] text-[#707070] truncate">{selectedProduct.brand}</p>
|
| 233 |
+
<p className="text-xs font-semibold text-[#333] truncate leading-tight">{selectedProduct.name}</p>
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
)}
|
| 237 |
+
{!selectedProduct && selectedMasks.size > 0 && (
|
| 238 |
+
<p className="text-xs text-[#0047AB] font-medium truncate flex-1">
|
| 239 |
+
{[...selectedMasks].map(getLabel).join(", ")}
|
| 240 |
+
</p>
|
| 241 |
+
)}
|
| 242 |
+
{!selectedProduct && selectedMasks.size === 0 && <div className="flex-1" />}
|
| 243 |
+
|
| 244 |
+
<div className="flex items-center gap-1 ml-auto shrink-0">
|
| 245 |
+
{canApply && (
|
| 246 |
+
<button
|
| 247 |
+
onClick={onApplyTexture}
|
| 248 |
+
disabled={isApplying}
|
| 249 |
+
className="pointer-events-auto flex items-center gap-1.5 bg-[#0047AB] text-white px-3 py-1.5 rounded-full text-xs font-semibold hover:bg-[#003a94] disabled:opacity-60 transition-colors"
|
| 250 |
+
>
|
| 251 |
+
{isApplying ? (
|
| 252 |
+
<Loader2 className="h-3 w-3 animate-spin" />
|
| 253 |
+
) : (
|
| 254 |
+
<Paintbrush className="h-3 w-3" />
|
| 255 |
+
)}
|
| 256 |
+
{isApplying ? "Aplicando..." : "Aplicar"}
|
| 257 |
+
</button>
|
| 258 |
+
)}
|
| 259 |
+
<button
|
| 260 |
+
onClick={onReset}
|
| 261 |
+
className="pointer-events-auto p-2 rounded-full hover:bg-[#eaf1ff] transition-colors"
|
| 262 |
+
>
|
| 263 |
+
<RefreshCw className="h-4 w-4 text-[#0047AB]" />
|
| 264 |
+
</button>
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
|
| 268 |
+
{/* ── Desktop bottom bar ───────────────────────────────────── */}
|
| 269 |
+
<div className="pointer-events-none hidden lg:flex absolute left-1/2 bottom-0 z-10 -translate-x-1/2 w-[80%] h-16 rounded-t-md bg-white border border-[#0047AB]/10 shadow-sm items-center justify-start gap-4 px-4">
|
| 270 |
+
{selectedProduct && (
|
| 271 |
+
<div className="pointer-events-none flex items-center gap-3">
|
| 272 |
+
<img
|
| 273 |
+
src={selectedProduct.image}
|
| 274 |
+
alt={selectedProduct.name}
|
| 275 |
+
className="w-10 h-10 object-cover rounded-md border border-gray-200"
|
| 276 |
+
/>
|
| 277 |
+
<div>
|
| 278 |
+
<p className="text-[#707070] text-xs">{selectedProduct.brand}</p>
|
| 279 |
+
<p className="font-semibold text-[#333333] text-sm leading-tight">
|
| 280 |
+
{selectedProduct.name}
|
| 281 |
+
</p>
|
| 282 |
+
</div>
|
| 283 |
+
</div>
|
| 284 |
+
)}
|
| 285 |
+
|
| 286 |
+
{selectedMasks.size > 0 && (
|
| 287 |
+
<p className="text-xs text-[#0047AB] font-medium truncate max-w-[260px]">
|
| 288 |
+
{[...selectedMasks].map(getLabel).join(", ")}
|
| 289 |
+
</p>
|
| 290 |
+
)}
|
| 291 |
+
|
| 292 |
+
<div className="flex-1" />
|
| 293 |
+
|
| 294 |
+
{canApply && (
|
| 295 |
+
<button
|
| 296 |
+
onClick={onApplyTexture}
|
| 297 |
+
disabled={isApplying}
|
| 298 |
+
className="pointer-events-auto flex items-center gap-2 bg-[#0047AB] text-white px-4 py-2 rounded-full text-sm font-semibold hover:bg-[#003a94] disabled:opacity-60 transition-colors"
|
| 299 |
+
>
|
| 300 |
+
{isApplying ? (
|
| 301 |
+
<Loader2 className="h-4 w-4 animate-spin" />
|
| 302 |
+
) : (
|
| 303 |
+
<Paintbrush className="h-4 w-4" />
|
| 304 |
+
)}
|
| 305 |
+
{isApplying ? "Aplicando..." : "Aplicar textura"}
|
| 306 |
+
</button>
|
| 307 |
+
)}
|
| 308 |
+
|
| 309 |
+
<button
|
| 310 |
+
onClick={onReset}
|
| 311 |
+
className="pointer-events-auto rounded-full bg-transparent px-4 py-2 text-sm font-medium text-[#0047AB] hover:bg-[#eaf1ff] flex items-center gap-2"
|
| 312 |
+
>
|
| 313 |
+
<span className="inline-flex items-center justify-center rounded-full bg-[#eaf1ff] p-2">
|
| 314 |
+
<RefreshCw className="h-4 w-4 text-[#0047AB]" />
|
| 315 |
+
</span>
|
| 316 |
+
Reiniciar
|
| 317 |
+
</button>
|
| 318 |
+
<button className="pointer-events-auto rounded-full bg-transparent px-4 py-2 text-sm font-medium text-[#0047AB] hover:bg-[#eaf1ff] flex items-center gap-2">
|
| 319 |
+
<span className="inline-flex items-center justify-center rounded-full bg-[#eaf1ff] p-2">
|
| 320 |
+
<RotateCcw className="h-4 w-4 text-[#0047AB]" />
|
| 321 |
+
</span>
|
| 322 |
+
Girar
|
| 323 |
+
</button>
|
| 324 |
+
</div>
|
| 325 |
+
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
+
);
|
| 329 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/features/roomVisualizer/RoomVisualizer.tsx
CHANGED
|
@@ -1,1000 +1,617 @@
|
|
| 1 |
-
import {
|
| 2 |
-
useCallback,
|
| 3 |
-
useEffect,
|
| 4 |
-
useRef,
|
| 5 |
-
useState,
|
| 6 |
-
type PointerEvent,
|
| 7 |
-
} from "react";
|
| 8 |
-
import { ChevronDown } from "lucide-react";
|
| 9 |
-
import { createRoot } from "react-dom/client";
|
| 10 |
-
import { useLocation, useNavigate } from "react-router-dom";
|
| 11 |
-
import {
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
import {
|
| 27 |
-
import {
|
| 28 |
-
import {
|
| 29 |
-
import
|
| 30 |
-
import
|
| 31 |
-
|
| 32 |
-
import { useApplyTexture } from "../../hooks/useApplyTexture";
|
| 33 |
-
import
|
| 34 |
-
|
| 35 |
-
type RoomVisualizerState = {
|
| 36 |
-
previewImage?: string;
|
| 37 |
-
filename?: string;
|
| 38 |
-
maskCount?: number;
|
| 39 |
-
};
|
| 40 |
-
|
| 41 |
-
// ── Hook para detectar mobile (< 1024px) ─────────────────────────────────────
|
| 42 |
-
function useIsMobile() {
|
| 43 |
-
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 1024);
|
| 44 |
-
useEffect(() => {
|
| 45 |
-
const handler = () => setIsMobile(window.innerWidth < 1024);
|
| 46 |
-
window.addEventListener("resize", handler);
|
| 47 |
-
return () => window.removeEventListener("resize", handler);
|
| 48 |
-
}, []);
|
| 49 |
-
return isMobile;
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
export default function RoomVisualizer() {
|
| 53 |
-
const location = useLocation();
|
| 54 |
-
const navigate = useNavigate();
|
| 55 |
-
const state = location.state as RoomVisualizerState | null;
|
| 56 |
-
const isMobile = useIsMobile();
|
| 57 |
-
|
| 58 |
-
const storedPreviewImage = useAppStore((store) => store.previewImage);
|
| 59 |
-
const segmentFilename = useAppStore((store) => store.segmentFilename);
|
| 60 |
-
const accumulatedFilename = useAppStore((store) => store.accumulatedFilename);
|
| 61 |
-
const setAccumulatedFilename = useAppStore(
|
| 62 |
-
|
| 63 |
-
);
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
);
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
const
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
const [
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
if (
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
],
|
| 281 |
-
);
|
| 282 |
-
|
| 283 |
-
const
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
const
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
);
|
| 304 |
-
return;
|
| 305 |
-
}
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
const
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
<
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
const
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
>
|
| 619 |
-
Cargando...
|
| 620 |
-
</div>
|
| 621 |
-
) : (
|
| 622 |
-
filteredProducts.map((product) => (
|
| 623 |
-
<button
|
| 624 |
-
key={product.id}
|
| 625 |
-
onClick={() => handleProductSelect(product.id)}
|
| 626 |
-
style={{
|
| 627 |
-
flexShrink: 0,
|
| 628 |
-
width: 64,
|
| 629 |
-
height: 64,
|
| 630 |
-
borderRadius: 12,
|
| 631 |
-
overflow: "hidden",
|
| 632 |
-
border:
|
| 633 |
-
openProductId === product.id
|
| 634 |
-
? "2.5px solid #0047AB"
|
| 635 |
-
: "2px solid #e5e7eb",
|
| 636 |
-
boxShadow:
|
| 637 |
-
openProductId === product.id ? "0 0 0 2px #dbe7ff" : "none",
|
| 638 |
-
cursor: "pointer",
|
| 639 |
-
padding: 0,
|
| 640 |
-
background: "none",
|
| 641 |
-
transition: "border-color 0.15s",
|
| 642 |
-
}}
|
| 643 |
-
>
|
| 644 |
-
<img
|
| 645 |
-
src={product.image}
|
| 646 |
-
alt={product.name}
|
| 647 |
-
style={{
|
| 648 |
-
width: "100%",
|
| 649 |
-
height: "100%",
|
| 650 |
-
objectFit: "cover",
|
| 651 |
-
display: "block",
|
| 652 |
-
}}
|
| 653 |
-
/>
|
| 654 |
-
</button>
|
| 655 |
-
))
|
| 656 |
-
)}
|
| 657 |
-
</div>
|
| 658 |
-
|
| 659 |
-
{/* Info del producto + íconos */}
|
| 660 |
-
<div
|
| 661 |
-
style={{
|
| 662 |
-
display: "flex",
|
| 663 |
-
alignItems: "center",
|
| 664 |
-
padding: "0 12px 10px",
|
| 665 |
-
gap: 8,
|
| 666 |
-
minHeight: 36,
|
| 667 |
-
}}
|
| 668 |
-
>
|
| 669 |
-
{selectedProduct ? (
|
| 670 |
-
<div style={{ flex: 1, minWidth: 0 }}>
|
| 671 |
-
<p
|
| 672 |
-
style={{
|
| 673 |
-
fontSize: 10,
|
| 674 |
-
color: "#707070",
|
| 675 |
-
textTransform: "uppercase",
|
| 676 |
-
letterSpacing: "0.05em",
|
| 677 |
-
margin: 0,
|
| 678 |
-
lineHeight: 1,
|
| 679 |
-
}}
|
| 680 |
-
>
|
| 681 |
-
{selectedProduct.brand}
|
| 682 |
-
</p>
|
| 683 |
-
<p
|
| 684 |
-
style={{
|
| 685 |
-
fontSize: 12,
|
| 686 |
-
fontWeight: 600,
|
| 687 |
-
color: "#333",
|
| 688 |
-
margin: 0,
|
| 689 |
-
overflow: "hidden",
|
| 690 |
-
textOverflow: "ellipsis",
|
| 691 |
-
whiteSpace: "nowrap",
|
| 692 |
-
}}
|
| 693 |
-
>
|
| 694 |
-
{selectedProduct.name}
|
| 695 |
-
</p>
|
| 696 |
-
</div>
|
| 697 |
-
) : (
|
| 698 |
-
<div style={{ flex: 1 }} />
|
| 699 |
-
)}
|
| 700 |
-
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
| 701 |
-
<button
|
| 702 |
-
onClick={() => setIsSearchOpen(!isSearchOpen)}
|
| 703 |
-
style={{
|
| 704 |
-
padding: 6,
|
| 705 |
-
borderRadius: 8,
|
| 706 |
-
border: "none",
|
| 707 |
-
background: "none",
|
| 708 |
-
cursor: "pointer",
|
| 709 |
-
}}
|
| 710 |
-
>
|
| 711 |
-
<Search style={{ width: 16, height: 16, color: "#0047AB" }} />
|
| 712 |
-
</button>
|
| 713 |
-
<button
|
| 714 |
-
style={{
|
| 715 |
-
padding: 6,
|
| 716 |
-
borderRadius: 8,
|
| 717 |
-
border: "none",
|
| 718 |
-
background: "none",
|
| 719 |
-
cursor: "pointer",
|
| 720 |
-
}}
|
| 721 |
-
>
|
| 722 |
-
<SlidersHorizontal
|
| 723 |
-
style={{ width: 16, height: 16, color: "#0047AB" }}
|
| 724 |
-
/>
|
| 725 |
-
</button>
|
| 726 |
-
</div>
|
| 727 |
-
</div>
|
| 728 |
-
</div>
|
| 729 |
-
);
|
| 730 |
-
|
| 731 |
-
// ── Desktop sidebar ───────────────────────────────────────────────────────
|
| 732 |
-
const DesktopSidebar = (
|
| 733 |
-
<div
|
| 734 |
-
style={{
|
| 735 |
-
width: "25%",
|
| 736 |
-
height: "100%",
|
| 737 |
-
display: "flex",
|
| 738 |
-
flexDirection: "column",
|
| 739 |
-
borderRight: "1px solid rgba(0,71,171,0.1)",
|
| 740 |
-
background: "#fff",
|
| 741 |
-
flexShrink: 0,
|
| 742 |
-
}}
|
| 743 |
-
>
|
| 744 |
-
<div style={{ padding: "24px 24px 0" }}>
|
| 745 |
-
<div style={{ height: 1, background: "#e5e7eb", width: "100%" }} />
|
| 746 |
-
|
| 747 |
-
{/* Barra de herramientas */}
|
| 748 |
-
<div
|
| 749 |
-
style={{ display: "flex", alignItems: "center", gap: 8, height: 46 }}
|
| 750 |
-
>
|
| 751 |
-
{!isSearchOpen && (
|
| 752 |
-
<button
|
| 753 |
-
onClick={() => setIsSearchOpen(true)}
|
| 754 |
-
className="p-3 rounded-lg border border-[#0047AB] bg-[#0047AB] text-white hover:bg-[#003a94] transition-all duration-300 flex items-center justify-center shadow-sm"
|
| 755 |
-
>
|
| 756 |
-
<Search className="w-5 h-5" />
|
| 757 |
-
</button>
|
| 758 |
-
)}
|
| 759 |
-
<button className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border border-[#0047AB] bg-white text-[#0047AB] hover:bg-[#eaf1ff] transition-all duration-300 shadow-sm">
|
| 760 |
-
<SlidersHorizontal className="w-4 h-4 text-[#0047AB]" />
|
| 761 |
-
<span className="font-medium text-sm">Filtros</span>
|
| 762 |
-
</button>
|
| 763 |
-
<div className="flex border border-gray-300 rounded-lg overflow-hidden shadow-sm">
|
| 764 |
-
<button
|
| 765 |
-
onClick={showList}
|
| 766 |
-
className={`p-2.5 flex items-center justify-center transition-colors ${viewMode === "list" ? "bg-[#0047AB] text-white" : "bg-white text-[#0047AB] hover:bg-[#eaf1ff] border-r border-[#dbe7ff]"}`}
|
| 767 |
-
>
|
| 768 |
-
<Menu className="w-5 h-5" />
|
| 769 |
-
</button>
|
| 770 |
-
<button
|
| 771 |
-
onClick={showGrid}
|
| 772 |
-
className={`p-2.5 flex items-center justify-center transition-colors ${viewMode === "grid" ? "bg-[#0047AB] text-white" : "bg-white text-[#0047AB] hover:bg-[#eaf1ff]"}`}
|
| 773 |
-
>
|
| 774 |
-
<LayoutGrid className="w-5 h-5" />
|
| 775 |
-
</button>
|
| 776 |
-
</div>
|
| 777 |
-
</div>
|
| 778 |
-
|
| 779 |
-
{isSearchOpen && (
|
| 780 |
-
<div className="animate-in slide-in-from-top-2 fade-in duration-300">
|
| 781 |
-
<div className="relative">
|
| 782 |
-
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
| 783 |
-
<input
|
| 784 |
-
autoFocus
|
| 785 |
-
type="text"
|
| 786 |
-
placeholder="¿Qué estás buscando?..."
|
| 787 |
-
value={searchQuery}
|
| 788 |
-
onChange={(e) => setSearchQuery(e.target.value)}
|
| 789 |
-
className="w-full pl-11 pr-10 py-3 rounded-lg border-2 border-[#0047AB] bg-white focus:outline-none transition-all text-sm text-[#333333]"
|
| 790 |
-
/>
|
| 791 |
-
<button
|
| 792 |
-
onClick={closeSearch}
|
| 793 |
-
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 bg-white rounded-full shadow-sm"
|
| 794 |
-
>
|
| 795 |
-
<X className="w-4 h-4" />
|
| 796 |
-
</button>
|
| 797 |
-
</div>
|
| 798 |
-
</div>
|
| 799 |
-
)}
|
| 800 |
-
</div>
|
| 801 |
-
|
| 802 |
-
{/* Lista de productos con acordeón por categoría */}
|
| 803 |
-
<div className="flex-1 overflow-y-auto py-2">
|
| 804 |
-
{loading ? (
|
| 805 |
-
<div className="flex items-center justify-center h-32 text-sm text-gray-400">
|
| 806 |
-
Cargando productos...
|
| 807 |
-
</div>
|
| 808 |
-
) : error ? (
|
| 809 |
-
<div className="flex items-center justify-center h-32 text-sm text-red-400">
|
| 810 |
-
{error}
|
| 811 |
-
</div>
|
| 812 |
-
) : searchQuery ? (
|
| 813 |
-
/* Búsqueda activa → lista plana de resultados */
|
| 814 |
-
<div className={viewMode === "grid" ? "px-4" : "px-2"}>
|
| 815 |
-
{filteredProducts.length === 0 ? (
|
| 816 |
-
<div className="flex items-center justify-center h-24 text-sm text-gray-400">
|
| 817 |
-
Sin resultados
|
| 818 |
-
</div>
|
| 819 |
-
) : viewMode === "grid" ? (
|
| 820 |
-
<div className="flex flex-col gap-4">
|
| 821 |
-
{chunkArray(filteredProducts, 3).map((group, i) => (
|
| 822 |
-
<ProductGroupCard
|
| 823 |
-
key={i}
|
| 824 |
-
group={group}
|
| 825 |
-
openProductId={openProductId}
|
| 826 |
-
onSelectProduct={handleProductSelect}
|
| 827 |
-
/>
|
| 828 |
-
))}
|
| 829 |
-
</div>
|
| 830 |
-
) : (
|
| 831 |
-
<div className="grid grid-cols-1 gap-3">
|
| 832 |
-
{filteredProducts.map((product) => (
|
| 833 |
-
<IndividualProductCard
|
| 834 |
-
key={product.id}
|
| 835 |
-
product={product}
|
| 836 |
-
isSelected={openProductId === product.id}
|
| 837 |
-
onToggle={() => handleProductSelect(product.id)}
|
| 838 |
-
/>
|
| 839 |
-
))}
|
| 840 |
-
</div>
|
| 841 |
-
)}
|
| 842 |
-
</div>
|
| 843 |
-
) : (
|
| 844 |
-
/* Sin búsqueda → acordeón por categoría */
|
| 845 |
-
<div className="flex flex-col">
|
| 846 |
-
{categories.map((cat) => {
|
| 847 |
-
const isOpen = isCategoryOpen(cat.id);
|
| 848 |
-
return (
|
| 849 |
-
<div
|
| 850 |
-
key={cat.id}
|
| 851 |
-
className="border-b border-gray-100 last:border-0"
|
| 852 |
-
>
|
| 853 |
-
<button
|
| 854 |
-
onClick={() => toggleCategory(cat.id)}
|
| 855 |
-
className="w-full flex items-center justify-between px-4 py-3 hover:bg-[#f4f8ff] transition-colors text-left"
|
| 856 |
-
>
|
| 857 |
-
<div className="flex items-center gap-2 min-w-0">
|
| 858 |
-
<span className="font-semibold text-sm text-[#333] truncate">
|
| 859 |
-
{cat.nombre}
|
| 860 |
-
</span>
|
| 861 |
-
<span className="text-xs text-[#0047AB] bg-[#eaf1ff] px-1.5 py-0.5 rounded-full flex-shrink-0">
|
| 862 |
-
{cat.products.length}
|
| 863 |
-
</span>
|
| 864 |
-
</div>
|
| 865 |
-
<ChevronDown
|
| 866 |
-
className={`w-4 h-4 text-[#0047AB] flex-shrink-0 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
|
| 867 |
-
/>
|
| 868 |
-
</button>
|
| 869 |
-
|
| 870 |
-
{isOpen && (
|
| 871 |
-
<div
|
| 872 |
-
className={
|
| 873 |
-
viewMode === "grid" ? "px-4 pb-4" : "px-2 pb-2"
|
| 874 |
-
}
|
| 875 |
-
>
|
| 876 |
-
{viewMode === "grid" ? (
|
| 877 |
-
<div className="flex flex-col gap-4">
|
| 878 |
-
{chunkArray(cat.products, 3).map((group, i) => (
|
| 879 |
-
<ProductGroupCard
|
| 880 |
-
key={i}
|
| 881 |
-
group={group}
|
| 882 |
-
openProductId={openProductId}
|
| 883 |
-
onSelectProduct={handleProductSelect}
|
| 884 |
-
/>
|
| 885 |
-
))}
|
| 886 |
-
</div>
|
| 887 |
-
) : (
|
| 888 |
-
<div className="grid grid-cols-1 gap-3">
|
| 889 |
-
{cat.products.map((product) => (
|
| 890 |
-
<IndividualProductCard
|
| 891 |
-
key={product.id}
|
| 892 |
-
product={product}
|
| 893 |
-
isSelected={openProductId === product.id}
|
| 894 |
-
onToggle={() => handleProductSelect(product.id)}
|
| 895 |
-
/>
|
| 896 |
-
))}
|
| 897 |
-
</div>
|
| 898 |
-
)}
|
| 899 |
-
</div>
|
| 900 |
-
)}
|
| 901 |
-
</div>
|
| 902 |
-
);
|
| 903 |
-
})}
|
| 904 |
-
</div>
|
| 905 |
-
)}
|
| 906 |
-
</div>
|
| 907 |
-
</div>
|
| 908 |
-
);
|
| 909 |
-
|
| 910 |
-
return (
|
| 911 |
-
<div
|
| 912 |
-
style={{
|
| 913 |
-
height: "100svh",
|
| 914 |
-
width: "100%",
|
| 915 |
-
background: "#fff",
|
| 916 |
-
fontFamily: "sans-serif",
|
| 917 |
-
color: "#000",
|
| 918 |
-
display: "flex",
|
| 919 |
-
flexDirection: "column",
|
| 920 |
-
}}
|
| 921 |
-
>
|
| 922 |
-
{isApplying && (
|
| 923 |
-
<div
|
| 924 |
-
aria-hidden={!isApplying}
|
| 925 |
-
style={{
|
| 926 |
-
position: "fixed",
|
| 927 |
-
inset: 0,
|
| 928 |
-
display: "flex",
|
| 929 |
-
alignItems: "center",
|
| 930 |
-
justifyContent: "center",
|
| 931 |
-
background: "rgba(0,0,0,0.45)",
|
| 932 |
-
zIndex: 9999,
|
| 933 |
-
backdropFilter: "blur(2px)",
|
| 934 |
-
WebkitBackdropFilter: "blur(2px)",
|
| 935 |
-
}}
|
| 936 |
-
>
|
| 937 |
-
<div
|
| 938 |
-
style={{
|
| 939 |
-
display: "flex",
|
| 940 |
-
flexDirection: "column",
|
| 941 |
-
alignItems: "center",
|
| 942 |
-
gap: 12,
|
| 943 |
-
padding: 20,
|
| 944 |
-
borderRadius: 12,
|
| 945 |
-
background: "rgba(255,255,255,0.98)",
|
| 946 |
-
boxShadow: "0 6px 24px rgba(0,0,0,0.3)",
|
| 947 |
-
minWidth: 220,
|
| 948 |
-
}}
|
| 949 |
-
>
|
| 950 |
-
<div
|
| 951 |
-
style={{
|
| 952 |
-
width: 48,
|
| 953 |
-
height: 48,
|
| 954 |
-
borderRadius: 24,
|
| 955 |
-
border: "4px solid #e6e6e6",
|
| 956 |
-
borderTopColor: "#0047AB",
|
| 957 |
-
animation: "hr-spin 1s linear infinite",
|
| 958 |
-
}}
|
| 959 |
-
/>
|
| 960 |
-
<div style={{ fontSize: 14, color: "#111", textAlign: "center" }}>
|
| 961 |
-
Generando imagen… Por favor espera
|
| 962 |
-
</div>
|
| 963 |
-
</div>
|
| 964 |
-
<style>{`@keyframes hr-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }`}</style>
|
| 965 |
-
</div>
|
| 966 |
-
)}
|
| 967 |
-
{isMobile ? (
|
| 968 |
-
// ── Layout Mobile: imagen arriba, strip de thumbnails abajo ──────────
|
| 969 |
-
<div
|
| 970 |
-
style={{
|
| 971 |
-
flex: 1,
|
| 972 |
-
display: "flex",
|
| 973 |
-
flexDirection: "column",
|
| 974 |
-
minHeight: 0,
|
| 975 |
-
}}
|
| 976 |
-
>
|
| 977 |
-
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
| 978 |
-
<RoomPreviewPanel {...previewPanelProps} />
|
| 979 |
-
</div>
|
| 980 |
-
{MobileProductStrip}
|
| 981 |
-
</div>
|
| 982 |
-
) : (
|
| 983 |
-
// ── Layout Desktop: sidebar izquierda + imagen derecha ───────────────
|
| 984 |
-
<div
|
| 985 |
-
style={{
|
| 986 |
-
flex: 1,
|
| 987 |
-
display: "flex",
|
| 988 |
-
flexDirection: "row",
|
| 989 |
-
minHeight: 0,
|
| 990 |
-
}}
|
| 991 |
-
>
|
| 992 |
-
{DesktopSidebar}
|
| 993 |
-
<div style={{ flex: 1, minWidth: 0, overflow: "hidden" }}>
|
| 994 |
-
<RoomPreviewPanel {...previewPanelProps} />
|
| 995 |
-
</div>
|
| 996 |
-
</div>
|
| 997 |
-
)}
|
| 998 |
-
</div>
|
| 999 |
-
);
|
| 1000 |
-
}
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
useCallback,
|
| 3 |
+
useEffect,
|
| 4 |
+
useRef,
|
| 5 |
+
useState,
|
| 6 |
+
type PointerEvent,
|
| 7 |
+
} from "react";
|
| 8 |
+
import { ChevronDown } from "lucide-react";
|
| 9 |
+
import { createRoot } from "react-dom/client";
|
| 10 |
+
import { useLocation, useNavigate } from "react-router-dom";
|
| 11 |
+
import {
|
| 12 |
+
LayoutGrid,
|
| 13 |
+
Search,
|
| 14 |
+
SlidersHorizontal,
|
| 15 |
+
Menu,
|
| 16 |
+
X,
|
| 17 |
+
} from "lucide-react";
|
| 18 |
+
import Swal from "sweetalert2";
|
| 19 |
+
import {
|
| 20 |
+
WhatsappShareButton, WhatsappIcon,
|
| 21 |
+
TelegramShareButton, TelegramIcon,
|
| 22 |
+
TwitterShareButton, XIcon,
|
| 23 |
+
FacebookShareButton, FacebookIcon,
|
| 24 |
+
EmailShareButton, EmailIcon,
|
| 25 |
+
} from "react-share";
|
| 26 |
+
import { ProductGroupCard, IndividualProductCard } from "./ProductCards";
|
| 27 |
+
import { useRoomVisualizer } from "./roomVisualizerHooks";
|
| 28 |
+
import { useCatalogProducts } from "./useCatalogProducts";
|
| 29 |
+
import { RoomPreviewPanel } from "./RoomPreviewPanel";
|
| 30 |
+
import useAppStore from "../../store/useAppStore";
|
| 31 |
+
import { useSegmentCanvas } from "../../hooks/useSegmentCanvas";
|
| 32 |
+
import { useApplyTexture } from "../../hooks/useApplyTexture";
|
| 33 |
+
import { API_BASE } from "../../api/client";
|
| 34 |
+
|
| 35 |
+
type RoomVisualizerState = {
|
| 36 |
+
previewImage?: string;
|
| 37 |
+
filename?: string;
|
| 38 |
+
maskCount?: number;
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
// ── Hook para detectar mobile (< 1024px) ─────────────────────────────────────
|
| 42 |
+
function useIsMobile() {
|
| 43 |
+
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 1024);
|
| 44 |
+
useEffect(() => {
|
| 45 |
+
const handler = () => setIsMobile(window.innerWidth < 1024);
|
| 46 |
+
window.addEventListener("resize", handler);
|
| 47 |
+
return () => window.removeEventListener("resize", handler);
|
| 48 |
+
}, []);
|
| 49 |
+
return isMobile;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
export default function RoomVisualizer() {
|
| 53 |
+
const location = useLocation();
|
| 54 |
+
const navigate = useNavigate();
|
| 55 |
+
const state = location.state as RoomVisualizerState | null;
|
| 56 |
+
const isMobile = useIsMobile();
|
| 57 |
+
|
| 58 |
+
const storedPreviewImage = useAppStore((store) => store.previewImage);
|
| 59 |
+
const segmentFilename = useAppStore((store) => store.segmentFilename);
|
| 60 |
+
const accumulatedFilename = useAppStore((store) => store.accumulatedFilename);
|
| 61 |
+
const setAccumulatedFilename = useAppStore((store) => store.setAccumulatedFilename);
|
| 62 |
+
const setSegmentResult = useAppStore((store) => store.setSegmentResult);
|
| 63 |
+
const setPreviewImage = useAppStore((store) => store.setPreviewImage);
|
| 64 |
+
|
| 65 |
+
// Restaurar segmentFilename y previewImage cuando se abre una sesión del historial
|
| 66 |
+
useEffect(() => {
|
| 67 |
+
if (state?.filename && state.filename !== segmentFilename) {
|
| 68 |
+
setSegmentResult(state.filename, state.maskCount ?? 0);
|
| 69 |
+
setAccumulatedFilename(null); // limpiar ediciones de sesión anterior
|
| 70 |
+
}
|
| 71 |
+
if (state?.previewImage) {
|
| 72 |
+
setPreviewImage(state.previewImage);
|
| 73 |
+
}
|
| 74 |
+
// Solo al montar — state no cambia tras la navegación
|
| 75 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 76 |
+
}, []);
|
| 77 |
+
|
| 78 |
+
const [currentPreviewImage, setCurrentPreviewImage] = useState<string | null>(() => {
|
| 79 |
+
if (accumulatedFilename) return `${API_BASE}/seg/image/${accumulatedFilename}`;
|
| 80 |
+
return state?.previewImage ?? storedPreviewImage ?? null;
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
const [zoom, setZoom] = useState(1);
|
| 84 |
+
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
| 85 |
+
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null);
|
| 86 |
+
const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
|
| 87 |
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
| 88 |
+
|
| 89 |
+
const { products, categories, loading, error } = useCatalogProducts();
|
| 90 |
+
|
| 91 |
+
// null = sin interacción (primera categoría abierta por defecto)
|
| 92 |
+
// Set vacío = usuario cerró todo deliberadamente
|
| 93 |
+
const [openCategoryIds, setOpenCategoryIds] = useState<Set<string> | null>(null);
|
| 94 |
+
|
| 95 |
+
const isCategoryOpen = useCallback(
|
| 96 |
+
(id: string) => {
|
| 97 |
+
if (openCategoryIds === null) {
|
| 98 |
+
return categories.length > 0 && id === categories[0].id;
|
| 99 |
+
}
|
| 100 |
+
return openCategoryIds.has(id);
|
| 101 |
+
},
|
| 102 |
+
[openCategoryIds, categories],
|
| 103 |
+
);
|
| 104 |
+
|
| 105 |
+
const toggleCategory = useCallback((id: string) => {
|
| 106 |
+
setOpenCategoryIds((prev) => {
|
| 107 |
+
const base =
|
| 108 |
+
prev === null
|
| 109 |
+
? categories.length > 0
|
| 110 |
+
? new Set([categories[0].id])
|
| 111 |
+
: new Set<string>()
|
| 112 |
+
: new Set(prev);
|
| 113 |
+
if (base.has(id)) base.delete(id);
|
| 114 |
+
else base.add(id);
|
| 115 |
+
return base;
|
| 116 |
+
});
|
| 117 |
+
}, [categories]);
|
| 118 |
+
|
| 119 |
+
const {
|
| 120 |
+
viewMode, showGrid, showList,
|
| 121 |
+
openProductId, handleSelectProduct, selectedProduct,
|
| 122 |
+
isSearchOpen, setIsSearchOpen,
|
| 123 |
+
searchQuery, setSearchQuery, closeSearch,
|
| 124 |
+
filteredProducts, chunkArray,
|
| 125 |
+
} = useRoomVisualizer(products);
|
| 126 |
+
|
| 127 |
+
useEffect(() => { showList(); }, [showList]);
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
const {
|
| 131 |
+
canvasRef,
|
| 132 |
+
hoveredMask,
|
| 133 |
+
selectedMasks,
|
| 134 |
+
segmentMeta,
|
| 135 |
+
handleCanvasMouseMove,
|
| 136 |
+
handleCanvasMouseLeave,
|
| 137 |
+
handleCanvasClick,
|
| 138 |
+
clearSelection,
|
| 139 |
+
} = useSegmentCanvas(segmentFilename);
|
| 140 |
+
|
| 141 |
+
const { applyTexture, isApplying, resetResult } = useApplyTexture();
|
| 142 |
+
|
| 143 |
+
const applyTextureWith = useCallback(
|
| 144 |
+
async (texturePath: string) => {
|
| 145 |
+
if (!segmentFilename || selectedMasks.size === 0) return;
|
| 146 |
+
const baseFilename = accumulatedFilename ?? segmentFilename;
|
| 147 |
+
try {
|
| 148 |
+
const data = await applyTexture(baseFilename, [...selectedMasks], texturePath, segmentFilename);
|
| 149 |
+
if (data?.output_url) {
|
| 150 |
+
setCurrentPreviewImage(`${API_BASE}${data.output_url}?t=${Date.now()}`);
|
| 151 |
+
setAccumulatedFilename(data.output_filename);
|
| 152 |
+
clearSelection();
|
| 153 |
+
}
|
| 154 |
+
} catch {
|
| 155 |
+
// error ya guardado en el hook
|
| 156 |
+
}
|
| 157 |
+
},
|
| 158 |
+
[applyTexture, segmentFilename, selectedMasks, clearSelection, accumulatedFilename, setAccumulatedFilename],
|
| 159 |
+
);
|
| 160 |
+
|
| 161 |
+
const handleApplyTexture = useCallback(async () => {
|
| 162 |
+
if (selectedProduct) await applyTextureWith(selectedProduct.ref);
|
| 163 |
+
}, [applyTextureWith, selectedProduct]);
|
| 164 |
+
|
| 165 |
+
const handleProductSelect = useCallback(
|
| 166 |
+
async (id: string | number | null) => {
|
| 167 |
+
handleSelectProduct(id);
|
| 168 |
+
if (!id || selectedMasks.size === 0 || !segmentFilename) return;
|
| 169 |
+
const product = products.find((p) => p.id === id);
|
| 170 |
+
if (product) await applyTextureWith(product.ref);
|
| 171 |
+
},
|
| 172 |
+
[handleSelectProduct, selectedMasks, segmentFilename, products, applyTextureWith],
|
| 173 |
+
);
|
| 174 |
+
|
| 175 |
+
const handleReset = useCallback(() => {
|
| 176 |
+
const original = state?.previewImage ?? storedPreviewImage ?? null;
|
| 177 |
+
setCurrentPreviewImage(original);
|
| 178 |
+
setAccumulatedFilename(null);
|
| 179 |
+
clearSelection();
|
| 180 |
+
resetResult();
|
| 181 |
+
}, [state, storedPreviewImage, setAccumulatedFilename, clearSelection, resetResult]);
|
| 182 |
+
|
| 183 |
+
const handleDownload = useCallback(async () => {
|
| 184 |
+
if (!currentPreviewImage) return;
|
| 185 |
+
const response = await fetch(currentPreviewImage);
|
| 186 |
+
const blob = await response.blob();
|
| 187 |
+
const url = URL.createObjectURL(blob);
|
| 188 |
+
const a = document.createElement("a");
|
| 189 |
+
a.href = url;
|
| 190 |
+
a.download = `hyper-reality-${Date.now()}.jpg`;
|
| 191 |
+
document.body.appendChild(a);
|
| 192 |
+
a.click();
|
| 193 |
+
document.body.removeChild(a);
|
| 194 |
+
URL.revokeObjectURL(url);
|
| 195 |
+
}, [currentPreviewImage]);
|
| 196 |
+
|
| 197 |
+
const handleShare = useCallback(async (): Promise<void> => {
|
| 198 |
+
let shareUrl = window.location.href;
|
| 199 |
+
const outputFilename = accumulatedFilename;
|
| 200 |
+
if (outputFilename) {
|
| 201 |
+
try {
|
| 202 |
+
const res = await fetch(`${API_BASE}/api/share`, {
|
| 203 |
+
method: "POST",
|
| 204 |
+
headers: { "Content-Type": "application/json" },
|
| 205 |
+
body: JSON.stringify({ output_filename: outputFilename, segment_filename: segmentFilename }),
|
| 206 |
+
});
|
| 207 |
+
if (res.ok) {
|
| 208 |
+
const data = (await res.json()) as { share_id: string };
|
| 209 |
+
shareUrl = `${window.location.origin}/app/share/${data.share_id}`;
|
| 210 |
+
}
|
| 211 |
+
} catch {
|
| 212 |
+
// fallback to current URL
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
const title = "My design with Hyper Reality Visualizer";
|
| 217 |
+
let reactRoot: ReturnType<typeof createRoot> | null = null;
|
| 218 |
+
|
| 219 |
+
await Swal.fire({
|
| 220 |
+
title: "Compartir diseño",
|
| 221 |
+
html: `
|
| 222 |
+
<div style="text-align:left">
|
| 223 |
+
<p style="font-size:13px;color:#666;margin-bottom:8px">Enlace de tu diseño:</p>
|
| 224 |
+
<div style="display:flex;gap:8px;align-items:center;margin-bottom:20px">
|
| 225 |
+
<input id="swal-share-url" readonly value="${shareUrl}"
|
| 226 |
+
style="flex:1;padding:8px 12px;border:1px solid #ddd;border-radius:8px;font-size:12px;background:#f9f9f9;outline:none;color:#333;" />
|
| 227 |
+
<button id="swal-copy-btn"
|
| 228 |
+
style="padding:8px 16px;background:#0047AB;color:white;border:none;border-radius:8px;cursor:pointer;font-size:13px;white-space:nowrap;font-weight:600;">
|
| 229 |
+
Copiar
|
| 230 |
+
</button>
|
| 231 |
+
</div>
|
| 232 |
+
<p style="font-size:13px;color:#666;margin-bottom:12px">Compartir en:</p>
|
| 233 |
+
<div id="swal-share-buttons" style="display:flex;gap:12px;flex-wrap:wrap;"></div>
|
| 234 |
+
</div>
|
| 235 |
+
`,
|
| 236 |
+
showConfirmButton: false,
|
| 237 |
+
showCloseButton: true,
|
| 238 |
+
width: 500,
|
| 239 |
+
didOpen: () => {
|
| 240 |
+
const copyBtn = document.getElementById("swal-copy-btn");
|
| 241 |
+
copyBtn?.addEventListener("click", async () => {
|
| 242 |
+
await navigator.clipboard.writeText(shareUrl).catch(() => {});
|
| 243 |
+
if (copyBtn) { copyBtn.textContent = "¡Copiado!"; copyBtn.style.background = "#16a34a"; }
|
| 244 |
+
setTimeout(() => {
|
| 245 |
+
if (copyBtn) { copyBtn.textContent = "Copiar"; copyBtn.style.background = "#0047AB"; }
|
| 246 |
+
}, 2000);
|
| 247 |
+
});
|
| 248 |
+
const container = document.getElementById("swal-share-buttons");
|
| 249 |
+
if (container) {
|
| 250 |
+
reactRoot = createRoot(container);
|
| 251 |
+
reactRoot.render(
|
| 252 |
+
<>
|
| 253 |
+
<WhatsappShareButton url={shareUrl} title={title}><WhatsappIcon size={48} round /></WhatsappShareButton>
|
| 254 |
+
<TelegramShareButton url={shareUrl} title={title}><TelegramIcon size={48} round /></TelegramShareButton>
|
| 255 |
+
<TwitterShareButton url={shareUrl} title={title}><XIcon size={48} round /></TwitterShareButton>
|
| 256 |
+
<FacebookShareButton url={shareUrl}><FacebookIcon size={48} round /></FacebookShareButton>
|
| 257 |
+
<EmailShareButton url={shareUrl} subject={title} body="Mira mi diseño de habitación:"><EmailIcon size={48} round /></EmailShareButton>
|
| 258 |
+
</>,
|
| 259 |
+
);
|
| 260 |
+
}
|
| 261 |
+
},
|
| 262 |
+
willClose: () => { reactRoot?.unmount(); },
|
| 263 |
+
});
|
| 264 |
+
}, [segmentFilename, accumulatedFilename]);
|
| 265 |
+
|
| 266 |
+
const clampOffset = useCallback(
|
| 267 |
+
(x: number, y: number, zoomValue: number) => {
|
| 268 |
+
const wrapper = wrapperRef.current;
|
| 269 |
+
if (!wrapper || imageSize.width === 0 || imageSize.height === 0) return { x, y };
|
| 270 |
+
const containerRect = wrapper.getBoundingClientRect();
|
| 271 |
+
const scaledWidth = imageSize.width * zoomValue;
|
| 272 |
+
const scaledHeight = imageSize.height * zoomValue;
|
| 273 |
+
const maxX = Math.max(0, (scaledWidth - containerRect.width) / 2);
|
| 274 |
+
const maxY = Math.max(0, (scaledHeight - containerRect.height) / 2);
|
| 275 |
+
return {
|
| 276 |
+
x: Math.max(-maxX, Math.min(maxX, x)),
|
| 277 |
+
y: Math.max(-maxY, Math.min(maxY, y)),
|
| 278 |
+
};
|
| 279 |
+
},
|
| 280 |
+
[imageSize],
|
| 281 |
+
);
|
| 282 |
+
|
| 283 |
+
const updateImageSize = useCallback(
|
| 284 |
+
(img: HTMLImageElement) => {
|
| 285 |
+
const wrapper = wrapperRef.current;
|
| 286 |
+
if (!wrapper) return;
|
| 287 |
+
const containerRect = wrapper.getBoundingClientRect();
|
| 288 |
+
const naturalRatio = img.naturalWidth / img.naturalHeight;
|
| 289 |
+
const containerRatio = containerRect.width / containerRect.height;
|
| 290 |
+
const width = naturalRatio > containerRatio ? containerRect.width : containerRect.height * naturalRatio;
|
| 291 |
+
const height = naturalRatio > containerRatio ? containerRect.width / naturalRatio : containerRect.height;
|
| 292 |
+
setImageSize({ width, height });
|
| 293 |
+
setOffset((current) => clampOffset(current.x, current.y, zoom));
|
| 294 |
+
},
|
| 295 |
+
[clampOffset, zoom],
|
| 296 |
+
);
|
| 297 |
+
|
| 298 |
+
const handleWheel = useCallback(
|
| 299 |
+
(event: WheelEvent) => {
|
| 300 |
+
event.preventDefault();
|
| 301 |
+
setZoom((currentZoom) => {
|
| 302 |
+
const next = Math.min(3, Math.max(1, currentZoom - event.deltaY * 0.0015));
|
| 303 |
+
setOffset((current) => clampOffset(current.x, current.y, next));
|
| 304 |
+
return next;
|
| 305 |
+
});
|
| 306 |
+
},
|
| 307 |
+
[clampOffset],
|
| 308 |
+
);
|
| 309 |
+
|
| 310 |
+
useEffect(() => {
|
| 311 |
+
const el = wrapperRef.current;
|
| 312 |
+
if (!el) return;
|
| 313 |
+
el.addEventListener("wheel", handleWheel, { passive: false });
|
| 314 |
+
return () => el.removeEventListener("wheel", handleWheel);
|
| 315 |
+
}, [handleWheel]);
|
| 316 |
+
|
| 317 |
+
const handlePointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
| 318 |
+
setDragStart({ x: event.clientX, y: event.clientY });
|
| 319 |
+
}, []);
|
| 320 |
+
|
| 321 |
+
const handlePointerMove = useCallback(
|
| 322 |
+
(event: PointerEvent<HTMLDivElement>) => {
|
| 323 |
+
if (!dragStart || zoom <= 1) return;
|
| 324 |
+
const dx = event.clientX - dragStart.x;
|
| 325 |
+
const dy = event.clientY - dragStart.y;
|
| 326 |
+
setOffset((current) => clampOffset(current.x + dx, current.y + dy, zoom));
|
| 327 |
+
setDragStart({ x: event.clientX, y: event.clientY });
|
| 328 |
+
},
|
| 329 |
+
[clampOffset, dragStart, zoom],
|
| 330 |
+
);
|
| 331 |
+
|
| 332 |
+
const handlePointerUp = useCallback(() => setDragStart(null), []);
|
| 333 |
+
|
| 334 |
+
// Props compartidos entre mobile y desktop para RoomPreviewPanel
|
| 335 |
+
const previewPanelProps = {
|
| 336 |
+
previewImage: currentPreviewImage,
|
| 337 |
+
offset,
|
| 338 |
+
zoom,
|
| 339 |
+
imageSize,
|
| 340 |
+
wrapperRef,
|
| 341 |
+
canvasRef,
|
| 342 |
+
selectedProduct,
|
| 343 |
+
selectedMasks,
|
| 344 |
+
hoveredMask,
|
| 345 |
+
segmentMeta,
|
| 346 |
+
isApplying,
|
| 347 |
+
onBack: () => navigate("/app"),
|
| 348 |
+
onPointerDown: handlePointerDown,
|
| 349 |
+
onPointerMove: handlePointerMove,
|
| 350 |
+
onPointerUp: handlePointerUp,
|
| 351 |
+
updateImageSize,
|
| 352 |
+
onCanvasMouseMove: handleCanvasMouseMove,
|
| 353 |
+
onCanvasMouseLeave: handleCanvasMouseLeave,
|
| 354 |
+
onCanvasClick: handleCanvasClick,
|
| 355 |
+
onApplyTexture: handleApplyTexture,
|
| 356 |
+
onReset: handleReset,
|
| 357 |
+
onDownload: handleDownload,
|
| 358 |
+
onShare: handleShare,
|
| 359 |
+
};
|
| 360 |
+
|
| 361 |
+
// ── Mobile strip: thumbnails + búsqueda ──────────────────────────────────
|
| 362 |
+
const MobileProductStrip = (
|
| 363 |
+
<div style={{ background: "#fff", borderTop: "1px solid #e5e7eb", flexShrink: 0 }}>
|
| 364 |
+
{isSearchOpen && (
|
| 365 |
+
<div style={{ padding: "8px 12px 4px" }}>
|
| 366 |
+
<div style={{ position: "relative" }}>
|
| 367 |
+
<Search style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "#9ca3af" }} />
|
| 368 |
+
<input
|
| 369 |
+
autoFocus
|
| 370 |
+
type="text"
|
| 371 |
+
placeholder="Buscar productos..."
|
| 372 |
+
value={searchQuery}
|
| 373 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 374 |
+
style={{
|
| 375 |
+
width: "100%",
|
| 376 |
+
paddingLeft: 34,
|
| 377 |
+
paddingRight: 32,
|
| 378 |
+
paddingTop: 8,
|
| 379 |
+
paddingBottom: 8,
|
| 380 |
+
borderRadius: 8,
|
| 381 |
+
border: "2px solid #0047AB",
|
| 382 |
+
outline: "none",
|
| 383 |
+
fontSize: 14,
|
| 384 |
+
color: "#333",
|
| 385 |
+
boxSizing: "border-box",
|
| 386 |
+
}}
|
| 387 |
+
/>
|
| 388 |
+
<button
|
| 389 |
+
onClick={closeSearch}
|
| 390 |
+
style={{ position: "absolute", right: 8, top: "50%", transform: "translateY(-50%)", background: "none", border: "none", cursor: "pointer", padding: 4 }}
|
| 391 |
+
>
|
| 392 |
+
<X style={{ width: 14, height: 14, color: "#9ca3af" }} />
|
| 393 |
+
</button>
|
| 394 |
+
</div>
|
| 395 |
+
</div>
|
| 396 |
+
)}
|
| 397 |
+
|
| 398 |
+
{/* Thumbnails con scroll horizontal */}
|
| 399 |
+
<div style={{ display: "flex", overflowX: "auto", gap: 8, padding: "8px 12px", scrollbarWidth: "none" }}>
|
| 400 |
+
{loading ? (
|
| 401 |
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "100%", height: 64, fontSize: 12, color: "#9ca3af" }}>
|
| 402 |
+
Cargando...
|
| 403 |
+
</div>
|
| 404 |
+
) : (
|
| 405 |
+
filteredProducts.map((product) => (
|
| 406 |
+
<button
|
| 407 |
+
key={product.id}
|
| 408 |
+
onClick={() => handleProductSelect(product.id)}
|
| 409 |
+
style={{
|
| 410 |
+
flexShrink: 0,
|
| 411 |
+
width: 64,
|
| 412 |
+
height: 64,
|
| 413 |
+
borderRadius: 12,
|
| 414 |
+
overflow: "hidden",
|
| 415 |
+
border: openProductId === product.id ? "2.5px solid #0047AB" : "2px solid #e5e7eb",
|
| 416 |
+
boxShadow: openProductId === product.id ? "0 0 0 2px #dbe7ff" : "none",
|
| 417 |
+
cursor: "pointer",
|
| 418 |
+
padding: 0,
|
| 419 |
+
background: "none",
|
| 420 |
+
transition: "border-color 0.15s",
|
| 421 |
+
}}
|
| 422 |
+
>
|
| 423 |
+
<img
|
| 424 |
+
src={product.image}
|
| 425 |
+
alt={product.name}
|
| 426 |
+
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
|
| 427 |
+
/>
|
| 428 |
+
</button>
|
| 429 |
+
))
|
| 430 |
+
)}
|
| 431 |
+
</div>
|
| 432 |
+
|
| 433 |
+
{/* Info del producto + íconos */}
|
| 434 |
+
<div style={{ display: "flex", alignItems: "center", padding: "0 12px 10px", gap: 8, minHeight: 36 }}>
|
| 435 |
+
{selectedProduct ? (
|
| 436 |
+
<div style={{ flex: 1, minWidth: 0 }}>
|
| 437 |
+
<p style={{ fontSize: 10, color: "#707070", textTransform: "uppercase", letterSpacing: "0.05em", margin: 0, lineHeight: 1 }}>
|
| 438 |
+
{selectedProduct.brand}
|
| 439 |
+
</p>
|
| 440 |
+
<p style={{ fontSize: 12, fontWeight: 600, color: "#333", margin: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
| 441 |
+
{selectedProduct.name}
|
| 442 |
+
</p>
|
| 443 |
+
</div>
|
| 444 |
+
) : (
|
| 445 |
+
<div style={{ flex: 1 }} />
|
| 446 |
+
)}
|
| 447 |
+
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
| 448 |
+
<button
|
| 449 |
+
onClick={() => setIsSearchOpen(!isSearchOpen)}
|
| 450 |
+
style={{ padding: 6, borderRadius: 8, border: "none", background: "none", cursor: "pointer" }}
|
| 451 |
+
>
|
| 452 |
+
<Search style={{ width: 16, height: 16, color: "#0047AB" }} />
|
| 453 |
+
</button>
|
| 454 |
+
<button
|
| 455 |
+
style={{ padding: 6, borderRadius: 8, border: "none", background: "none", cursor: "pointer" }}
|
| 456 |
+
>
|
| 457 |
+
<SlidersHorizontal style={{ width: 16, height: 16, color: "#0047AB" }} />
|
| 458 |
+
</button>
|
| 459 |
+
</div>
|
| 460 |
+
</div>
|
| 461 |
+
</div>
|
| 462 |
+
);
|
| 463 |
+
|
| 464 |
+
// ── Desktop sidebar ───────────────────────────────────────────────────────
|
| 465 |
+
const DesktopSidebar = (
|
| 466 |
+
<div style={{ width: "25%", height: "100%", display: "flex", flexDirection: "column", borderRight: "1px solid rgba(0,71,171,0.1)", background: "#fff", flexShrink: 0 }}>
|
| 467 |
+
<div style={{ padding: "24px 24px 0" }}>
|
| 468 |
+
<div style={{ height: 1, background: "#e5e7eb", width: "100%" }} />
|
| 469 |
+
|
| 470 |
+
{/* Barra de herramientas */}
|
| 471 |
+
<div style={{ display: "flex", alignItems: "center", gap: 8, height: 46 }}>
|
| 472 |
+
{!isSearchOpen && (
|
| 473 |
+
<button
|
| 474 |
+
onClick={() => setIsSearchOpen(true)}
|
| 475 |
+
className="p-3 rounded-lg border border-[#0047AB] bg-[#0047AB] text-white hover:bg-[#003a94] transition-all duration-300 flex items-center justify-center shadow-sm"
|
| 476 |
+
>
|
| 477 |
+
<Search className="w-5 h-5" />
|
| 478 |
+
</button>
|
| 479 |
+
)}
|
| 480 |
+
<button className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border border-[#0047AB] bg-white text-[#0047AB] hover:bg-[#eaf1ff] transition-all duration-300 shadow-sm">
|
| 481 |
+
<SlidersHorizontal className="w-4 h-4 text-[#0047AB]" />
|
| 482 |
+
<span className="font-medium text-sm">Filtros</span>
|
| 483 |
+
</button>
|
| 484 |
+
<div className="flex border border-gray-300 rounded-lg overflow-hidden shadow-sm">
|
| 485 |
+
<button
|
| 486 |
+
onClick={showList}
|
| 487 |
+
className={`p-2.5 flex items-center justify-center transition-colors ${viewMode === "list" ? "bg-[#0047AB] text-white" : "bg-white text-[#0047AB] hover:bg-[#eaf1ff] border-r border-[#dbe7ff]"}`}
|
| 488 |
+
>
|
| 489 |
+
<Menu className="w-5 h-5" />
|
| 490 |
+
</button>
|
| 491 |
+
<button
|
| 492 |
+
onClick={showGrid}
|
| 493 |
+
className={`p-2.5 flex items-center justify-center transition-colors ${viewMode === "grid" ? "bg-[#0047AB] text-white" : "bg-white text-[#0047AB] hover:bg-[#eaf1ff]"}`}
|
| 494 |
+
>
|
| 495 |
+
<LayoutGrid className="w-5 h-5" />
|
| 496 |
+
</button>
|
| 497 |
+
</div>
|
| 498 |
+
</div>
|
| 499 |
+
|
| 500 |
+
{isSearchOpen && (
|
| 501 |
+
<div className="animate-in slide-in-from-top-2 fade-in duration-300">
|
| 502 |
+
<div className="relative">
|
| 503 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
| 504 |
+
<input
|
| 505 |
+
autoFocus
|
| 506 |
+
type="text"
|
| 507 |
+
placeholder="¿Qué estás buscando?..."
|
| 508 |
+
value={searchQuery}
|
| 509 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 510 |
+
className="w-full pl-11 pr-10 py-3 rounded-lg border-2 border-[#0047AB] bg-white focus:outline-none transition-all text-sm text-[#333333]"
|
| 511 |
+
/>
|
| 512 |
+
<button
|
| 513 |
+
onClick={closeSearch}
|
| 514 |
+
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 bg-white rounded-full shadow-sm"
|
| 515 |
+
>
|
| 516 |
+
<X className="w-4 h-4" />
|
| 517 |
+
</button>
|
| 518 |
+
</div>
|
| 519 |
+
</div>
|
| 520 |
+
)}
|
| 521 |
+
</div>
|
| 522 |
+
|
| 523 |
+
{/* Lista de productos con acordeón por categoría */}
|
| 524 |
+
<div className="flex-1 overflow-y-auto py-2">
|
| 525 |
+
{loading ? (
|
| 526 |
+
<div className="flex items-center justify-center h-32 text-sm text-gray-400">Cargando productos...</div>
|
| 527 |
+
) : error ? (
|
| 528 |
+
<div className="flex items-center justify-center h-32 text-sm text-red-400">{error}</div>
|
| 529 |
+
) : searchQuery ? (
|
| 530 |
+
/* Búsqueda activa → lista plana de resultados */
|
| 531 |
+
<div className={viewMode === "grid" ? "px-4" : "px-2"}>
|
| 532 |
+
{filteredProducts.length === 0 ? (
|
| 533 |
+
<div className="flex items-center justify-center h-24 text-sm text-gray-400">Sin resultados</div>
|
| 534 |
+
) : viewMode === "grid" ? (
|
| 535 |
+
<div className="flex flex-col gap-4">
|
| 536 |
+
{chunkArray(filteredProducts, 3).map((group, i) => (
|
| 537 |
+
<ProductGroupCard key={i} group={group} openProductId={openProductId} onSelectProduct={handleProductSelect} />
|
| 538 |
+
))}
|
| 539 |
+
</div>
|
| 540 |
+
) : (
|
| 541 |
+
<div className="grid grid-cols-1 gap-3">
|
| 542 |
+
{filteredProducts.map((product) => (
|
| 543 |
+
<IndividualProductCard key={product.id} product={product} isSelected={openProductId === product.id} onToggle={() => handleProductSelect(product.id)} />
|
| 544 |
+
))}
|
| 545 |
+
</div>
|
| 546 |
+
)}
|
| 547 |
+
</div>
|
| 548 |
+
) : (
|
| 549 |
+
/* Sin búsqueda → acordeón por categoría */
|
| 550 |
+
<div className="flex flex-col">
|
| 551 |
+
{categories.map((cat) => {
|
| 552 |
+
const isOpen = isCategoryOpen(cat.id);
|
| 553 |
+
return (
|
| 554 |
+
<div key={cat.id} className="border-b border-gray-100 last:border-0">
|
| 555 |
+
<button
|
| 556 |
+
onClick={() => toggleCategory(cat.id)}
|
| 557 |
+
className="w-full flex items-center justify-between px-4 py-3 hover:bg-[#f4f8ff] transition-colors text-left"
|
| 558 |
+
>
|
| 559 |
+
<div className="flex items-center gap-2 min-w-0">
|
| 560 |
+
<span className="font-semibold text-sm text-[#333] truncate">{cat.nombre}</span>
|
| 561 |
+
<span className="text-xs text-[#0047AB] bg-[#eaf1ff] px-1.5 py-0.5 rounded-full flex-shrink-0">
|
| 562 |
+
{cat.products.length}
|
| 563 |
+
</span>
|
| 564 |
+
</div>
|
| 565 |
+
<ChevronDown
|
| 566 |
+
className={`w-4 h-4 text-[#0047AB] flex-shrink-0 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
|
| 567 |
+
/>
|
| 568 |
+
</button>
|
| 569 |
+
|
| 570 |
+
{isOpen && (
|
| 571 |
+
<div className={viewMode === "grid" ? "px-4 pb-4" : "px-2 pb-2"}>
|
| 572 |
+
{viewMode === "grid" ? (
|
| 573 |
+
<div className="flex flex-col gap-4">
|
| 574 |
+
{chunkArray(cat.products, 3).map((group, i) => (
|
| 575 |
+
<ProductGroupCard key={i} group={group} openProductId={openProductId} onSelectProduct={handleProductSelect} />
|
| 576 |
+
))}
|
| 577 |
+
</div>
|
| 578 |
+
) : (
|
| 579 |
+
<div className="grid grid-cols-1 gap-3">
|
| 580 |
+
{cat.products.map((product) => (
|
| 581 |
+
<IndividualProductCard key={product.id} product={product} isSelected={openProductId === product.id} onToggle={() => handleProductSelect(product.id)} />
|
| 582 |
+
))}
|
| 583 |
+
</div>
|
| 584 |
+
)}
|
| 585 |
+
</div>
|
| 586 |
+
)}
|
| 587 |
+
</div>
|
| 588 |
+
);
|
| 589 |
+
})}
|
| 590 |
+
</div>
|
| 591 |
+
)}
|
| 592 |
+
</div>
|
| 593 |
+
</div>
|
| 594 |
+
);
|
| 595 |
+
|
| 596 |
+
return (
|
| 597 |
+
<div style={{ height: "100svh", width: "100%", background: "#fff", fontFamily: "sans-serif", color: "#000", display: "flex", flexDirection: "column" }}>
|
| 598 |
+
{isMobile ? (
|
| 599 |
+
// ── Layout Mobile: imagen arriba, strip de thumbnails abajo ──────────
|
| 600 |
+
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
|
| 601 |
+
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
| 602 |
+
<RoomPreviewPanel {...previewPanelProps} />
|
| 603 |
+
</div>
|
| 604 |
+
{MobileProductStrip}
|
| 605 |
+
</div>
|
| 606 |
+
) : (
|
| 607 |
+
// ── Layout Desktop: sidebar izquierda + imagen derecha ───────────────
|
| 608 |
+
<div style={{ flex: 1, display: "flex", flexDirection: "row", minHeight: 0 }}>
|
| 609 |
+
{DesktopSidebar}
|
| 610 |
+
<div style={{ flex: 1, minWidth: 0, overflow: "hidden" }}>
|
| 611 |
+
<RoomPreviewPanel {...previewPanelProps} />
|
| 612 |
+
</div>
|
| 613 |
+
</div>
|
| 614 |
+
)}
|
| 615 |
+
</div>
|
| 616 |
+
);
|
| 617 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|