eduardo4547 commited on
Commit
cb5d9d0
·
verified ·
1 Parent(s): d2df13f

Upload 150 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +3 -0
  2. backend/.dockerignore +6 -6
  3. backend/.gitignore +33 -33
  4. backend/Dockerfile +29 -29
  5. backend/README.md +192 -192
  6. backend/admin.html +202 -202
  7. backend/core/config.py +92 -92
  8. backend/home.html +61 -61
  9. backend/logs/app.log +0 -0
  10. backend/main.py +128 -176
  11. backend/models/schemas.py +1 -3
  12. backend/preview.html +242 -242
  13. backend/requirements.txt +0 -1
  14. backend/routers/auth.py +7 -40
  15. backend/routers/catalog.py +240 -240
  16. backend/routers/segmentation.py +723 -760
  17. backend/run_server.bat +12 -12
  18. backend/services/gradio_client_service.py +104 -108
  19. backend/services/image_service.py +40 -12
  20. backend/services/inpainting_service.py +12 -204
  21. backend/services/sam2_service.py +0 -3
  22. backend/services/texture_service.py +851 -851
  23. backend/templates/classic_dashboard.html +0 -0
  24. backend/texturas/Texture_wpc_deck/DECK_gris.png +3 -0
  25. backend/texturas/Texture_wpc_deck/DECK_madera.png +3 -0
  26. backend/texturas/Texture_wpc_deck/DECK_madera_oscuro.png +3 -0
  27. backend/visualizador.html +125 -125
  28. frontend/.gitignore +24 -24
  29. frontend/FRONTEND_DOCUMENTATION.md +250 -250
  30. frontend/README.md +73 -73
  31. frontend/eslint.config.js +23 -23
  32. frontend/index.html +28 -35
  33. frontend/package.json +47 -47
  34. frontend/postcss.config.js +6 -6
  35. frontend/public/icons.svg +24 -24
  36. frontend/rewrite_css.py +508 -508
  37. frontend/scripts/generate-version.js +19 -19
  38. frontend/src/App.css +542 -542
  39. frontend/src/App.tsx +20 -20
  40. frontend/src/api/client.ts +100 -100
  41. frontend/src/assets/vite.svg +1 -1
  42. frontend/src/components/ui/LoadingScreen.tsx +33 -33
  43. frontend/src/data/roomSetupData.ts +329 -329
  44. frontend/src/features/roomSetup/RoomSetup.tsx +318 -334
  45. frontend/src/features/roomSetup/RoomSetupComponents.tsx +62 -62
  46. frontend/src/features/roomSetup/roomSetup.types.ts +6 -6
  47. frontend/src/features/roomSetup/roomSetupHooks.ts +33 -26
  48. frontend/src/features/roomVisualizer/MaskLayer.tsx +45 -45
  49. frontend/src/features/roomVisualizer/RoomPreviewPanel.tsx +329 -338
  50. 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, "&amp;")
163
- .replace(/</g, "&lt;")
164
- .replace(/>/g, "&gt;");
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, "&amp;")
163
+ .replace(/</g, "&lt;")
164
+ .replace(/>/g, "&gt;");
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 shutil
5
- import threading
6
- import time
7
- from pathlib import Path
8
-
9
- from dotenv import load_dotenv
10
- load_dotenv(Path(__file__).resolve().parent / ".env")
11
-
12
- from fastapi import FastAPI, Request
13
- from fastapi.responses import RedirectResponse, FileResponse
14
- from fastapi.middleware.cors import CORSMiddleware
15
- from fastapi.staticfiles import StaticFiles
16
-
17
- from core.config import logger
18
- from routers import auth, catalog, media, pages, segmentation, sessions, share, openai_image, active_sessions
19
- from routers.catalog import seed_catalog
20
-
21
- mimetypes.add_type("application/javascript", ".js", strict=True)
22
- mimetypes.add_type("text/css", ".css", strict=True)
23
- mimetypes.add_type("image/svg+xml", ".svg", strict=True)
24
-
25
- app = FastAPI(title="Hyper Reality Backend")
26
-
27
- app.add_middleware(
28
- CORSMiddleware,
29
- allow_origins=["*"],
30
- allow_credentials=True,
31
- allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
32
- allow_headers=["*"],
33
- )
34
-
35
-
36
- @app.middleware("http")
37
- async def remove_x_frame_options(request: Request, call_next):
38
- response = await call_next(request)
39
- if "x-frame-options" in response.headers:
40
- del response.headers["x-frame-options"]
41
- response.headers["Content-Security-Policy"] = "frame-ancestors *"
42
- return response
43
-
44
-
45
- # Routers
46
- # Mantener sólo la ruta de subida de imágenes (/api/upload-image).
47
- # Comentamos las demás inclusiones para deshabilitar funcionalidades
48
- # posteriores (segmentación, inpainting, sesiones, catálogo, etc.).
49
- # Re-activar routers necesarios para el frontend
50
- app.include_router(auth.router)
51
- app.include_router(media.router)
52
- app.include_router(pages.router)
53
- app.include_router(sessions.router)
54
- app.include_router(segmentation.router)
55
- app.include_router(openai_image.router)
56
- app.include_router(active_sessions.router)
57
- app.include_router(catalog.router)
58
-
59
- # Static files
60
- BASE_DIR = Path(__file__).resolve().parent
61
- UPLOADS_DIR = BASE_DIR / "uploads"
62
- FRONTEND_DIST = BASE_DIR.parent / "frontend" / "dist"
63
-
64
- UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
65
- app.mount("/uploads", StaticFiles(directory=UPLOADS_DIR), name="uploads")
66
-
67
- if (FRONTEND_DIST / "index.html").exists():
68
- # Montar la SPA únicamente en /app (el servidor tiene su propia landing en /)
69
- app.mount("/app", StaticFiles(directory=FRONTEND_DIST, html=True), name="frontend_app")
70
- # También exponer los assets estáticos en la raíz /assets para que los
71
- # archivos generados por Vite (con rutas absolutas `/assets/...`) sean
72
- # resueltos correctamente cuando la SPA se carga desde /app.
73
- assets_dir = FRONTEND_DIST / "assets"
74
- if assets_dir.exists():
75
- app.mount("/assets", StaticFiles(directory=assets_dir), name="frontend_assets")
76
- # Servir manifest y favicon desde el dist para PWA/links absolutos
77
- manifest = FRONTEND_DIST / "manifest.json"
78
- favicon = FRONTEND_DIST / "favicon.svg"
79
- if manifest.exists():
80
- @app.get("/manifest.json")
81
- async def _manifest():
82
- return FileResponse(str(manifest), media_type="application/manifest+json")
83
- if favicon.exists():
84
- @app.get("/favicon.svg")
85
- async def _favicon():
86
- return FileResponse(str(favicon), media_type="image/svg+xml")
87
-
88
- # Ruta raíz: servir `backend/home.html` cuando exista (landing del servidor)
89
- HOME_HTML = BASE_DIR / "home.html"
90
- if HOME_HTML.exists():
91
- @app.get("/")
92
- async def _root_home():
93
- return FileResponse(str(HOME_HTML), media_type="text/html")
94
- else:
95
- # Si no existe `home.html`, redirigimos a la SPA en /app
96
- @app.get("/")
97
- async def _root_redirect():
98
- return RedirectResponse(url="/app")
99
-
100
-
101
- # Frontend watcher (development helper)
102
- FRONTEND_DIR = BASE_DIR.parent / "frontend"
103
- FRONTEND_SRC = FRONTEND_DIR / "src"
104
-
105
-
106
- def scan_frontend_sources() -> dict:
107
- if not FRONTEND_SRC.exists():
108
- return {}
109
- files = {}
110
- for path in FRONTEND_SRC.rglob("*"):
111
- if path.is_file() and path.suffix in {".ts", ".tsx", ".js", ".jsx", ".css", ".json", ".html"}:
112
- files[path] = path.stat().st_mtime
113
- for extra in [FRONTEND_DIR / "vite.config.ts", FRONTEND_DIR / "package.json", FRONTEND_DIR / "tsconfig.json"]:
114
- if extra.exists():
115
- files[extra] = extra.stat().st_mtime
116
- return files
117
-
118
-
119
- def run_frontend_build() -> None:
120
- if not FRONTEND_DIR.exists():
121
- return
122
- # Allow disabling automatic frontend builds (useful in production or CI without Node installed)
123
- if os.getenv("SKIP_FRONTEND_BUILD", "").lower() in {"1", "true", "yes"}:
124
- print("[backend] SKIP_FRONTEND_BUILD is set — skipping frontend build.")
125
- return
126
-
127
- npm_path = shutil.which("npm")
128
- if not npm_path:
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
- return None
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
- if not MONGODB_URI:
115
- doc = next((c for c in _DEFAULT_CLIENTS if c["_id"] == client_id), None)
116
- datos = doc or {"nombre": "Cliente Desconocido", "color_primario": "#f97316", "created_at": _now_iso()}
117
- else:
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
- if not MONGODB_URI:
171
- _DEFAULT_CLIENTS.append(doc)
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": "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})
 
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.openai_service import generate_image_with_openai
53
- from PIL import Image
54
- from services.scene_service import (
55
- build_adaptive_plan,
56
- generate_label_map,
57
- infer_scene_type,
58
- normalize_priority,
59
- normalize_scene_hint,
60
- rank_exterior_candidates,
61
- rank_interior_candidates,
62
- )
63
- from services.segmentation_service import (
64
- generate_guided_label_map,
65
- parse_mask_index,
66
- parse_rgb_color,
67
- segment_exterior_brick_sync,
68
- segment_exterior_depth_sync,
69
- segment_exterior_grabcut_sync,
70
- segment_exterior_hybrid_sync,
71
- segment_video_sync,
72
- )
73
- from services.texture_service import (
74
- apply_local_texture_sync,
75
- build_texture_preview_jpeg,
76
- generate_texture_variations,
77
- list_available_textures,
78
- resolve_texture_path,
79
- )
80
-
81
- import cv2
82
-
83
- router = APIRouter(prefix="/seg")
84
-
85
-
86
- @router.get("/", response_class=HTMLResponse)
87
- async def home() -> HTMLResponse:
88
- dashboard_html = load_classic_dashboard_html().replace(
89
- "__FRONTEND_DEBUG_ENABLED__",
90
- "true" if FRONTEND_DEBUG else "false",
91
- )
92
- return HTMLResponse(content=dashboard_html)
93
-
94
-
95
- @router.post("/upload_video")
96
- async def upload_video(file: UploadFile = File(...)) -> dict[str, Any]:
97
- if not file.content_type or not file.content_type.startswith("video/"):
98
- raise HTTPException(status_code=400, detail="Only video files are allowed")
99
-
100
- safe_name = Path(file.filename or "uploaded_video").name
101
- if not safe_name:
102
- raise HTTPException(status_code=400, detail="Invalid filename")
103
-
104
- destination = VIDEO_UPLOAD_DIR / safe_name
105
- content = await file.read()
106
- if not content:
107
- raise HTTPException(status_code=400, detail="Uploaded video is empty")
108
-
109
- destination.write_bytes(content)
110
- return {
111
- "message": "Video uploaded successfully",
112
- "filename": safe_name,
113
- "url": f"/seg/video/{safe_name}",
114
- }
115
-
116
-
117
- @router.post("/upload_async")
118
- async def upload_image_async(
119
- background_tasks: BackgroundTasks,
120
- file: UploadFile = File(...),
121
- ) -> dict[str, Any]:
122
- if not file.content_type or not file.content_type.startswith("image/"):
123
- raise HTTPException(status_code=400, detail="Only image files are allowed")
124
-
125
- content = await file.read()
126
- job_id = uuid.uuid4().hex
127
- with jobs_lock:
128
- jobs[job_id] = {
129
- "kind": "upload",
130
- "status": "processing",
131
- "stage": "queued",
132
- "progress": 2,
133
- "message": "Queued for segmentation",
134
- "created_at": utc_now_iso(),
135
- "updated_at": utc_now_iso(),
136
- }
137
-
138
- background_tasks.add_task(run_upload_job, job_id, content, file.filename or "uploaded_image")
139
- return {
140
- "processing": True,
141
- "job_id": job_id,
142
- "status": "processing",
143
- "stage": "queued",
144
- "progress": 2,
145
- "message": "Upload accepted. Segmentation started in background.",
146
- "status_url": f"/seg/jobs/{job_id}",
147
- }
148
-
149
-
150
- @router.post("/segment_guided")
151
- async def segment_guided(payload: GuidedSegmentRequest) -> dict[str, Any]:
152
- started = log_timing_start("SEGMENT_GUIDED")
153
- try:
154
- from services.image_service import load_image_rgb_for_edit
155
- safe_name, image_rgb = await asyncio.to_thread(load_image_rgb_for_edit, payload.filename)
156
- label_map, ranked_scores = await asyncio.to_thread(
157
- generate_guided_label_map,
158
- image_rgb,
159
- [list(point) for point in payload.point_coords],
160
- list(payload.point_labels),
161
- list(payload.box_xyxy) if payload.box_xyxy is not None else [],
162
- payload.multimask_output,
163
- )
164
-
165
- guided_owner = f"{Path(safe_name).stem}_guided.jpg"
166
- label_owner = await asyncio.to_thread(save_label_map_for_owner, guided_owner, label_map)
167
- available_indices = list(range(1, len(ranked_scores) + 1))
168
-
169
- return {
170
- "message": "Guided segmentation completed",
171
- "filename": safe_name,
172
- "original_filename_for_apply": label_owner,
173
- "mask_count": len(ranked_scores),
174
- "available_mask_indices": available_indices,
175
- "recommended_mask_index": 1,
176
- "scores": [round(score, 6) for score in ranked_scores],
177
- }
178
- finally:
179
- log_timing_end("SEGMENT_GUIDED", started)
180
- try:
181
- release_resources()
182
- except Exception:
183
- logger.exception("Error releasing resources after SEGMENT_GUIDED")
184
-
185
-
186
- @router.post("/suggest_exterior_masks")
187
- async def suggest_exterior_masks(payload: ExteriorSuggestRequest) -> dict[str, Any]:
188
- started = log_timing_start("EXTERIOR_SUGGEST")
189
- try:
190
- from services.image_service import load_image_rgb_for_edit
191
- safe_name, image_rgb = await asyncio.to_thread(load_image_rgb_for_edit, payload.filename)
192
-
193
- label_owner_name = Path(payload.original_filename).name if payload.original_filename else safe_name
194
-
195
- masks_dir = UPLOAD_DIR / "masks"
196
- label_path = masks_dir / f"{label_owner_name}_labels.png"
197
- if not label_path.exists():
198
- label_map, _ = await asyncio.to_thread(generate_label_map, image_rgb)
199
- label_owner_name = await asyncio.to_thread(save_label_map_for_owner, label_owner_name, label_map)
200
- label_path = masks_dir / f"{label_owner_name}_labels.png"
201
-
202
- label_map_arr = cv2.imread(str(label_path), cv2.IMREAD_GRAYSCALE)
203
- if label_map_arr is None:
204
- raise HTTPException(status_code=404, detail="Label map not found")
205
-
206
- candidates = rank_exterior_candidates(
207
- label_map_arr,
208
- payload.top_k,
209
- target=payload.target,
210
- min_area_ratio=payload.min_area_ratio,
211
- max_area_ratio=payload.max_area_ratio,
212
- )
213
-
214
- return {
215
- "message": "Exterior mask suggestions generated",
216
- "filename": safe_name,
217
- "original_filename_for_apply": label_owner_name,
218
- "suggestions": candidates,
219
- "target": payload.target,
220
- }
221
- finally:
222
- log_timing_end("EXTERIOR_SUGGEST", started)
223
- try:
224
- release_resources()
225
- except Exception:
226
- logger.exception("Error releasing resources after EXTERIOR_SUGGEST")
227
-
228
-
229
- @router.post("/analyze_scene")
230
- async def analyze_scene(payload: SceneAnalyzeRequest) -> dict[str, Any]:
231
- started = log_timing_start("ANALYZE_SCENE")
232
- try:
233
- from services.image_service import load_image_rgb_for_edit
234
- safe_name, image_rgb = await asyncio.to_thread(load_image_rgb_for_edit, payload.filename)
235
-
236
- label_owner_name = Path(payload.original_filename).name if payload.original_filename else safe_name
237
- masks_dir = UPLOAD_DIR / "masks"
238
- label_path = masks_dir / f"{label_owner_name}_labels.png"
239
-
240
- if not label_path.exists():
241
- label_map, _ = await asyncio.to_thread(generate_label_map, image_rgb)
242
- label_owner_name = await asyncio.to_thread(save_label_map_for_owner, label_owner_name, label_map)
243
-
244
- scene_info = await asyncio.to_thread(
245
- infer_scene_type,
246
- image_rgb,
247
- payload.semantic_keywords,
248
- payload.exterior_target,
249
- payload.min_area_ratio,
250
- payload.max_area_ratio,
251
- )
252
- scene_type = scene_info["scene_type"]
253
- scene_hint = normalize_scene_hint(payload.scene_hint)
254
- effective_scene = scene_hint if scene_hint != "auto" else scene_type
255
-
256
- adaptive_plan = build_adaptive_plan(effective_scene, payload.priority, payload.exterior_target)
257
-
258
- label_map_arr = cv2.imread(str(masks_dir / f"{label_owner_name}_labels.png"), cv2.IMREAD_GRAYSCALE)
259
- suggestions: list[dict[str, Any]] = []
260
- if label_map_arr is not None:
261
- if effective_scene == "exterior":
262
- suggestions = rank_exterior_candidates(
263
- label_map_arr, payload.top_k,
264
- target=payload.exterior_target,
265
- min_area_ratio=payload.min_area_ratio,
266
- max_area_ratio=payload.max_area_ratio,
267
- )
268
- else:
269
- suggestions = rank_interior_candidates(label_map_arr, payload.top_k)
270
-
271
- return {
272
- "message": "Scene analysis completed",
273
- "filename": safe_name,
274
- "original_filename_for_apply": label_owner_name,
275
- "scene_type": scene_type,
276
- "effective_scene": effective_scene,
277
- "confidence": scene_info["confidence"],
278
- "signals": scene_info["signals"],
279
- "adaptive_plan": adaptive_plan,
280
- "suggestions": suggestions,
281
- "priority": normalize_priority(payload.priority),
282
- }
283
- finally:
284
- log_timing_end("ANALYZE_SCENE", started)
285
- try:
286
- release_resources()
287
- except Exception:
288
- logger.exception("Error releasing resources after ANALYZE_SCENE")
289
-
290
-
291
- @router.post("/segment_adaptive")
292
- async def segment_adaptive(payload: SegmentAdaptiveRequest) -> dict[str, Any]:
293
- started = log_timing_start("SEGMENT_ADAPTIVE")
294
- try:
295
- from services.image_service import load_image_rgb_for_edit
296
- safe_name, image_rgb = await asyncio.to_thread(load_image_rgb_for_edit, payload.filename)
297
-
298
- scene_info = await asyncio.to_thread(
299
- infer_scene_type,
300
- image_rgb,
301
- payload.semantic_keywords,
302
- payload.exterior_target,
303
- )
304
- scene_hint = normalize_scene_hint(payload.scene_hint)
305
- effective_scene = scene_hint if scene_hint != "auto" else scene_info["scene_type"]
306
- priority = normalize_priority(payload.priority)
307
- adaptive_plan = build_adaptive_plan(effective_scene, priority, payload.exterior_target)
308
-
309
- label_owner_name = Path(payload.original_filename).name if payload.original_filename else safe_name
310
-
311
- if effective_scene == "exterior":
312
- from services.segmentation_service import segment_exterior_depth_sync as seg_depth
313
- from models.schemas import ExteriorDepthRequest as DepthReq
314
-
315
- depth_payload = DepthReq(
316
- filename=payload.filename,
317
- exterior_target=payload.exterior_target,
318
- rect_xywh=payload.rect_xywh,
319
- smooth_strength=1,
320
- sam2_merge_top_k=12,
321
- iterations=6,
322
- use_semantic_hint=True,
323
- use_depth_hint=True,
324
- semantic_keywords=payload.semantic_keywords,
325
- )
326
- result = await asyncio.to_thread(seg_depth, depth_payload)
327
- else:
328
- label_map, _ = await asyncio.to_thread(generate_label_map, image_rgb)
329
- label_owner_name = await asyncio.to_thread(save_label_map_for_owner, label_owner_name, label_map)
330
- top_k = 4 if priority == "speed" else (10 if priority == "quality" else 6)
331
- candidates = rank_interior_candidates(label_map, top_k)
332
- result = {
333
- "message": "Interior adaptive segmentation completed",
334
- "filename": safe_name,
335
- "original_filename_for_apply": label_owner_name,
336
- "scene_type": effective_scene,
337
- "suggestions": candidates,
338
- }
339
-
340
- result["adaptive_plan"] = adaptive_plan
341
- result["detected_scene_type"] = scene_info["scene_type"]
342
- result["effective_scene"] = effective_scene
343
- result["scene_confidence"] = scene_info["confidence"]
344
- return result
345
- finally:
346
- log_timing_end("SEGMENT_ADAPTIVE", started)
347
- try:
348
- release_resources()
349
- except Exception:
350
- logger.exception("Error releasing resources after SEGMENT_ADAPTIVE")
351
-
352
-
353
- @router.post("/segment_video")
354
- async def segment_video(payload: SegmentVideoRequest) -> dict[str, Any]:
355
- try:
356
- return await asyncio.to_thread(segment_video_sync, payload)
357
- except HTTPException:
358
- raise
359
- except Exception as exc:
360
- raise HTTPException(status_code=500, detail=f"Video segmentation failed: {exc}") from exc
361
-
362
-
363
- @router.post("/segment_exterior_grabcut")
364
- async def segment_exterior_grabcut(payload: ExteriorGrabCutRequest) -> dict[str, Any]:
365
- try:
366
- return await asyncio.to_thread(segment_exterior_grabcut_sync, payload)
367
- except HTTPException:
368
- raise
369
- except Exception as exc:
370
- raise HTTPException(status_code=500, detail=f"GrabCut segmentation failed: {exc}") from exc
371
-
372
-
373
- @router.post("/segment_exterior_hybrid")
374
- async def segment_exterior_hybrid(payload: ExteriorHybridRequest) -> dict[str, Any]:
375
- try:
376
- return await asyncio.to_thread(segment_exterior_hybrid_sync, payload)
377
- except HTTPException:
378
- raise
379
- except Exception as exc:
380
- raise HTTPException(status_code=500, detail=f"Hybrid exterior segmentation failed: {exc}") from exc
381
-
382
-
383
- @router.post("/segment_exterior_brick")
384
- async def segment_exterior_brick(payload: ExteriorBrickRequest) -> dict[str, Any]:
385
- try:
386
- return await asyncio.to_thread(segment_exterior_brick_sync, payload)
387
- except HTTPException:
388
- raise
389
- except Exception as exc:
390
- raise HTTPException(status_code=500, detail=f"Brick segmentation failed: {exc}") from exc
391
-
392
-
393
- @router.post("/segment_exterior_depth")
394
- async def segment_exterior_depth(payload: ExteriorDepthRequest) -> dict[str, Any]:
395
- try:
396
- return await asyncio.to_thread(segment_exterior_depth_sync, payload)
397
- except HTTPException:
398
- raise
399
- except Exception as exc:
400
- raise HTTPException(status_code=500, detail=f"Depth exterior segmentation failed: {exc}") from exc
401
-
402
-
403
- @router.post("/apply_texture_ai")
404
- async def apply_texture_ai(
405
- payload: ApplyTextureAIRequest,
406
- background_tasks: BackgroundTasks,
407
- ) -> dict[str, Any]:
408
- started = log_timing_start("APPLY_TEXTURE_AI")
409
- try:
410
- # Try to run OpenAI generation synchronously within the quick timeout
411
- def _run_openai():
412
- safe_name = Path(payload.filename).name
413
- image_path = UPLOAD_DIR / safe_name
414
- if not image_path.exists():
415
- image_path = OUTPUT_DIR / safe_name
416
-
417
- if not image_path.exists():
418
- return {"error": f"Image not found: {payload.filename}"}
419
-
420
- try:
421
- pil = Image.open(str(image_path)).convert("RGBA")
422
- except Exception as e:
423
- return {"error": f"Cannot open image: {e}"}
424
-
425
- texture = payload.texture_name or payload.prompt or ""
426
- png_bytes, msg = generate_image_with_openai(None, pil, texture)
427
- if png_bytes is None:
428
- return {"error": msg}
429
-
430
- out_name = f"{Path(safe_name).stem}_ai_{uuid.uuid4().hex}.png"
431
- out_path = OUTPUT_DIR / out_name
432
- out_path.write_bytes(png_bytes)
433
- return {"message": msg, "filename": out_name, "url": f"/seg/ai/{out_name}", "processing": False}
434
-
435
- result = await asyncio.wait_for(asyncio.to_thread(_run_openai), timeout=SD_QUICK_TIMEOUT_SECONDS)
436
- log_timing_end("APPLY_TEXTURE_AI", started)
437
- try:
438
- release_resources()
439
- except Exception:
440
- logger.exception("Error releasing resources after APPLY_TEXTURE_AI")
441
- result["processing"] = False
442
- return result
443
- except asyncio.TimeoutError:
444
- job_id = uuid.uuid4().hex
445
- with jobs_lock:
446
- jobs[job_id] = {"status": "processing", "created_at": utc_now_iso(), "updated_at": utc_now_iso()}
447
- # enqueue background job that runs the OpenAI generation
448
- def _run_openai_job(job_id_inner: str, payload_inner: ApplyTextureAIRequest) -> None:
449
- try:
450
- res = _run_openai()
451
- with jobs_lock:
452
- if "error" in res:
453
- jobs[job_id_inner] = {"status": "failed", "error": res.get("error"), "updated_at": utc_now_iso()}
454
- else:
455
- jobs[job_id_inner] = {"status": "done", "result": res, "updated_at": utc_now_iso()}
456
- except Exception as exc:
457
- with jobs_lock:
458
- jobs[job_id_inner] = {"status": "failed", "error": str(exc), "updated_at": utc_now_iso()}
459
-
460
- background_tasks.add_task(_run_openai_job, job_id, payload)
461
- log_timing_end("APPLY_TEXTURE_AI", started)
462
- try:
463
- release_resources()
464
- except Exception:
465
- pass
466
- return {
467
- "processing": True,
468
- "job_id": job_id,
469
- "message": "Inpainting is taking longer than expected and continues in background.",
470
- "status_url": f"/seg/jobs/{job_id}",
471
- }
472
- except HTTPException:
473
- log_timing_end("APPLY_TEXTURE_AI", started)
474
- try:
475
- release_resources()
476
- except Exception:
477
- pass
478
- raise
479
- except Exception as exc:
480
- log_timing_end("APPLY_TEXTURE_AI", started)
481
- try:
482
- release_resources()
483
- except Exception:
484
- pass
485
- raise HTTPException(status_code=500, detail=f"Inpainting failed: {exc}") from exc
486
-
487
-
488
- @router.get("/jobs/{job_id}")
489
- async def get_job_status(job_id: str) -> dict[str, Any]:
490
- with jobs_lock:
491
- job = jobs.get(job_id)
492
-
493
- if job is None:
494
- raise HTTPException(status_code=404, detail="Job not found")
495
-
496
- if job.get("status") == "processing":
497
- kind = str(job.get("kind", "generic"))
498
- stage = str(job.get("stage", "processing"))
499
- progress = int(job.get("progress", 0) or 0)
500
- eta_seconds: int | None = None
501
-
502
- if kind == "upload" and stage == "segmenting_with_sam2":
503
- stage_started_at_text = job.get("stage_started_at")
504
- estimated_seconds = float(job.get("estimated_seconds", 0.0) or 0.0)
505
- if stage_started_at_text and estimated_seconds > 0:
506
- try:
507
- stage_started_at = datetime.fromisoformat(str(stage_started_at_text))
508
- elapsed = (datetime.now(timezone.utc) - stage_started_at).total_seconds()
509
- eta_seconds = max(0, int(estimated_seconds - elapsed))
510
- estimated_progress = int(min(95, 30 + (max(0.0, elapsed) / estimated_seconds) * 60))
511
- progress = max(progress, estimated_progress)
512
- except ValueError:
513
- pass
514
-
515
- stale_limit_seconds = UPLOAD_JOB_STALE_SECONDS if kind == "upload" else SD_JOB_STALE_SECONDS
516
- created_at_text = job.get("created_at")
517
- if created_at_text:
518
- try:
519
- created_at = datetime.fromisoformat(str(created_at_text))
520
- age_seconds = (datetime.now(timezone.utc) - created_at).total_seconds()
521
- if age_seconds > stale_limit_seconds:
522
- return {
523
- "processing": False,
524
- "status": "timeout",
525
- "message": "The process is taking too long. Please retry.",
526
- "job_id": job_id,
527
- }
528
- except ValueError:
529
- pass
530
-
531
- response: dict[str, Any] = {
532
- "processing": True,
533
- "status": "processing",
534
- "job_id": job_id,
535
- "kind": kind,
536
- "stage": stage,
537
- "progress": progress,
538
- "message": str(job.get("message", "Still processing.")),
539
- }
540
- if eta_seconds is not None:
541
- response["eta_seconds"] = eta_seconds
542
- return response
543
-
544
- if job.get("status") == "done":
545
- result = cast(dict[str, Any], job.get("result", {}))
546
- result["processing"] = False
547
- result["job_id"] = job_id
548
- result["status"] = "done"
549
- return result
550
-
551
- if job.get("status") == "failed":
552
- return {
553
- "processing": False,
554
- "status": "failed",
555
- "job_id": job_id,
556
- "message": job.get("error", "Background task failed"),
557
- }
558
-
559
- return {"processing": True, "status": "processing", "job_id": job_id, "message": "Still processing."}
560
-
561
-
562
- @router.post("/apply_color")
563
- async def apply_color(payload: ApplyColorRequest) -> dict[str, Any]:
564
- started = log_timing_start("APPLY_COLOR")
565
- try:
566
- safe_name = Path(payload.filename).name
567
- if not safe_name:
568
- raise HTTPException(status_code=400, detail="Invalid filename")
569
-
570
- label_safe_name = Path(payload.original_filename).name if payload.original_filename else safe_name
571
-
572
- image_path = UPLOAD_DIR / safe_name
573
- if not image_path.exists():
574
- image_path = OUTPUT_DIR / safe_name
575
- if not image_path.exists() or not image_path.is_file():
576
- raise HTTPException(status_code=404, detail=f"Image not found: {safe_name}")
577
-
578
- image_bgr = cv2.imread(str(image_path))
579
- if image_bgr is None:
580
- raise HTTPException(status_code=400, detail="Image could not be read")
581
-
582
- mask_index = parse_mask_index(payload.mask_filename)
583
- red, green, blue = parse_rgb_color(payload.color)
584
-
585
- label_path = UPLOAD_DIR / "masks" / f"{label_safe_name}_labels.png"
586
- label_map = cv2.imread(str(label_path), cv2.IMREAD_GRAYSCALE)
587
- if label_map is None:
588
- raise HTTPException(
589
- status_code=404,
590
- detail="Label map not found. Upload the image first to generate segments.",
591
- )
592
-
593
- segmentation = label_map == mask_index
594
- if not segmentation.any():
595
- raise HTTPException(status_code=400, detail=f"Segment index {mask_index} not found in label map.")
596
-
597
- edited_image = image_bgr.copy()
598
- edited_image[segmentation] = (blue, green, red)
599
-
600
- original_stem = Path(label_safe_name).stem
601
- out_filename = f"{original_stem}_edit.jpg"
602
- out_path = UPLOAD_DIR / out_filename
603
- if not cv2.imwrite(str(out_path), edited_image):
604
- raise HTTPException(status_code=500, detail="Failed to save edited image")
605
-
606
- return {
607
- "message": "Color applied successfully",
608
- "output_filename": out_filename,
609
- "output_url": f"/seg/image/{out_filename}",
610
- }
611
- finally:
612
- log_timing_end("APPLY_COLOR", started)
613
- try:
614
- release_resources()
615
- except Exception:
616
- logger.exception("Error releasing resources after APPLY_COLOR")
617
-
618
-
619
- @router.post("/apply_texture")
620
- async def apply_texture(payload: ApplyTextureRequest) -> dict[str, Any]:
621
- try:
622
- result = await asyncio.to_thread(apply_local_texture_sync, payload)
623
- result["processing"] = False
624
- return result
625
- except HTTPException:
626
- raise
627
- except Exception as exc:
628
- raise HTTPException(status_code=500, detail=f"Texture apply failed: {exc}") from exc
629
-
630
-
631
- @router.get("/textures")
632
- async def get_textures() -> dict[str, Any]:
633
- return {"textures": list_available_textures()}
634
-
635
-
636
- class _GenerateVariationsRequest(BaseModel):
637
- texture_name: str
638
-
639
- class Config:
640
- extra = "ignore"
641
-
642
-
643
- @router.post("/textures/generate")
644
- async def generate_variations(payload: _GenerateVariationsRequest) -> dict[str, Any]:
645
- if not payload.texture_name:
646
- raise HTTPException(status_code=400, detail="texture_name is required")
647
- try:
648
- variations = await asyncio.to_thread(generate_texture_variations, payload.texture_name)
649
- return {"variations": variations}
650
- except HTTPException:
651
- raise
652
- except Exception as exc:
653
- raise HTTPException(status_code=500, detail=f"Variation generation failed: {exc}") from exc
654
-
655
-
656
- @router.get("/texture-preview/{filename:path}")
657
- async def get_texture_preview(filename: str) -> Response:
658
- texture_path = resolve_texture_path(filename)
659
- jpeg = await asyncio.to_thread(build_texture_preview_jpeg, texture_path)
660
- return Response(content=jpeg, media_type="image/jpeg", headers={"Cache-Control": "public, max-age=3600"})
661
-
662
-
663
- @router.get("/video/{filename}")
664
- async def get_video(filename: str) -> FileResponse:
665
- if Path(filename).name != filename:
666
- raise HTTPException(status_code=400, detail="Invalid file name")
667
- video_path = VIDEO_UPLOAD_DIR / filename
668
- if not video_path.exists() or not video_path.is_file():
669
- raise HTTPException(status_code=404, detail="Video not found")
670
- return FileResponse(video_path)
671
-
672
-
673
- @router.get("/output-video/{filename}")
674
- async def get_output_video(filename: str) -> FileResponse:
675
- if Path(filename).name != filename:
676
- raise HTTPException(status_code=400, detail="Invalid file name")
677
- video_path = VIDEO_OUTPUT_DIR / filename
678
- if not video_path.exists() or not video_path.is_file():
679
- raise HTTPException(status_code=404, detail="Output video not found")
680
- return FileResponse(video_path)
681
-
682
-
683
- @router.get("/image/{filename}")
684
- async def get_image(filename: str) -> FileResponse:
685
- if Path(filename).name != filename:
686
- raise HTTPException(status_code=400, detail="Invalid file name")
687
- image_path = UPLOAD_DIR / filename
688
- if not image_path.exists() or not image_path.is_file():
689
- raise HTTPException(status_code=404, detail="Image not found")
690
- return FileResponse(image_path)
691
-
692
-
693
- @router.post("/masks/reclassify/{filename}")
694
- async def reclassify_mask_metadata(filename: str) -> dict[str, Any]:
695
- """Re-run semantic classification on an already-segmented image and overwrite its metadata JSON."""
696
- import json as _json
697
- safe = Path(filename).name
698
- if not safe:
699
- raise HTTPException(status_code=400, detail="Invalid filename")
700
-
701
- masks_dir = UPLOAD_DIR / "masks"
702
- label_path = masks_dir / f"{safe}_labels.png"
703
- if not label_path.exists():
704
- raise HTTPException(status_code=404, detail="Label map not found — upload the image first")
705
-
706
- label_map = cv2.imread(str(label_path), cv2.IMREAD_GRAYSCALE)
707
- if label_map is None:
708
- raise HTTPException(status_code=500, detail="Could not read label map")
709
-
710
- image_path = UPLOAD_DIR / safe
711
- image_rgb: Any = None
712
- if image_path.exists():
713
- img_bgr = cv2.imread(str(image_path))
714
- if img_bgr is not None:
715
- image_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
716
-
717
- from services.scene_service import classify_all_label_map_segments
718
- h, w = label_map.shape[:2]
719
- segments_meta = await asyncio.to_thread(
720
- classify_all_label_map_segments, label_map, w, h, image_rgb
721
- )
722
- meta_path = masks_dir / f"{safe}_labels_meta.json"
723
- meta_path.write_text(_json.dumps({"segments": segments_meta}, ensure_ascii=False), encoding="utf-8")
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
- # Desactivado por petición del usuario para usar el nuevo flujo simplificado con OpenAI
22
- return False
23
-
24
-
25
-
26
- def _call_gradio_sync(image_path: Path, space_url: str) -> tuple[np.ndarray, int]:
27
- """
28
- Synchronous Gradio call safe to invoke from a background thread.
29
- Returns (label_map, mask_count).
30
- Raises on any error so the caller can handle fallback.
31
- """
32
- from gradio_client import Client, file # type: ignore
33
-
34
- # 300s timeout: ZeroGPU cold start + SAM2+DINO inference can take 60-120s
35
- # client = Client(space_url, httpx_kwargs={"timeout": 300.0})
36
- client = Client(space_url) # httpx_kwargs no es compatible con todas las versiones
37
-
38
-
39
- # segment_for_backend returns (overlay_image, combined_json_str)
40
- _overlay_file, combined_json_str = client.predict(
41
- file(str(image_path)),
42
- api_name="/segment",
43
- )
44
-
45
- if not isinstance(combined_json_str, str):
46
- raise ValueError(f"Unexpected response type from Gradio Space: {type(combined_json_str)}")
47
-
48
- combined: dict = json.loads(combined_json_str)
49
-
50
- if "error" in combined:
51
- raise RuntimeError(f"Gradio Space error: {combined['error'][:500]}")
52
-
53
- label_map_b64: str = combined.get("label_map_b64", "")
54
- if not label_map_b64:
55
- return np.zeros((1, 1), dtype=np.uint8), 0
56
-
57
- # Decode PNG-encoded label map (lossless uint8 grayscale)
58
- label_map_bytes = base64.b64decode(label_map_b64)
59
- pil_label = Image.open(io.BytesIO(label_map_bytes))
60
- label_map = np.array(pil_label, dtype=np.uint8)
61
- mask_count = int(label_map.max())
62
-
63
- entorno = combined.get("entorno", "?")
64
- motor = combined.get("motor", "?")
65
- logger.info(
66
- "Gradio Space segmentation: entorno=%s motor=%s mask_count=%d",
67
- entorno, motor, mask_count,
68
- )
69
-
70
- return label_map, mask_count
71
-
72
-
73
- def segment_via_gradio_sync(image_path: Path) -> tuple[np.ndarray, int]:
74
- """
75
- Blocking call to the Gradio Space from a sync context (background task thread).
76
- Tries the GPU Space first; if it fails, falls back to the CPU Space.
77
- Raises RuntimeError if both fail or neither is configured.
78
- """
79
- if not is_gradio_enabled():
80
- raise RuntimeError("GRADIO_SPACE_URL is not configured")
81
-
82
- gpu_error: Exception | None = None
83
- try:
84
- logger.info("Calling GPU Gradio Space: %s", GRADIO_SPACE_URL)
85
- return _call_gradio_sync(image_path, GRADIO_SPACE_URL)
86
- except Exception as e:
87
- gpu_error = e
88
- logger.warning("GPU Space failed (%s), trying CPU fallback...", gpu_error)
89
-
90
- if not GRADIO_CPU_FALLBACK_URL:
91
- raise RuntimeError(f"GPU Gradio Space failed and no CPU fallback configured. Error: {gpu_error}")
92
-
93
- try:
94
- logger.info("Calling CPU fallback Space: %s", GRADIO_CPU_FALLBACK_URL)
95
- return _call_gradio_sync(image_path, GRADIO_CPU_FALLBACK_URL)
96
- except Exception as exc_cpu:
97
- raise RuntimeError(
98
- f"Both Gradio Spaces failed.\n"
99
- f" GPU ({GRADIO_SPACE_URL}): {gpu_error}\n"
100
- f" CPU ({GRADIO_CPU_FALLBACK_URL}): {exc_cpu}"
101
- ) from exc_cpu
102
-
103
-
104
- async def segment_via_gradio(image_path: Path) -> tuple[np.ndarray, int]:
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
- # We are simplifying the flow: skipping SAM 2 segmentation.
139
- # The result will have 0 masks.
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": 0,
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": "Upload complete (Simplified flow)",
155
  "result": result,
156
  "updated_at": utc_now_iso(),
157
  }
158
- logger.info(f"[JOB {job_id}] done (simplified, 0 masks)")
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 UPLOAD_DIR, OUTPUT_DIR, logger, utc_now_iso
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
- Usa DALL-E 2 para editar la imagen de la casa reemplazando las texturas.
71
- Inspirado en la lógica de 'imagneConaI/app.py'.
72
- """
73
- api_key = os.getenv("OPENAI_API_KEY")
74
- if not api_key:
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
- if "error" in result:
207
- jobs[job_id] = {
208
- "status": "failed",
209
- "error": result["error"],
210
- "updated_at": utc_now_iso(),
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

  • SHA256: 55daa6f6e902f484bb4e0327609a3aab8ffd8cd45e37a2eb8b0575983ef0b97b
  • Pointer size: 132 Bytes
  • Size of remote file: 1.44 MB
backend/texturas/Texture_wpc_deck/DECK_madera.png ADDED

Git LFS Details

  • SHA256: 781cd371ee4970775220d90950f3a03d1735b15dc5175eb7c3c338e344607d5b
  • Pointer size: 132 Bytes
  • Size of remote file: 1.95 MB
backend/texturas/Texture_wpc_deck/DECK_madera_oscuro.png ADDED

Git LFS Details

  • SHA256: aad5dc59e369431186149eaa324c91098037b1a3d4cb258045bcd923897f5843
  • Pointer size: 132 Bytes
  • Size of remote file: 1.88 MB
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
- name="viewport"
9
- content="width=device-width, initial-scale=1.0, viewport-fit=cover"
10
- />
11
-
12
- <!-- PWA -->
13
- <link rel="manifest" href="/manifest.json" />
14
- <meta name="theme-color" content="#0047AB" />
15
-
16
- <!-- iOS PWA: fullscreen al instalarse desde Safari -->
17
- <meta name="apple-mobile-web-app-capable" content="yes" />
18
- <meta name="mobile-web-app-capable" content="yes" />
19
- <meta
20
- name="apple-mobile-web-app-status-bar-style"
21
- content="black-translucent"
22
- />
23
- <meta name="apple-mobile-web-app-title" content="Hyper Reality" />
24
- <link rel="apple-touch-icon" href="/favicon.svg" />
25
-
26
- <!-- Icono -->
27
- <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
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
- useLoadSessionHistory,
21
- deleteSessionFromBackend,
22
- } from "../../hooks/useSessionSync";
23
- import { useCallback } from "react";
24
-
25
- export default function RoomSetup() {
26
- useLoadSessionHistory();
27
- const [categoriaActiva, setCategoriaActiva] = useState("todos");
28
- const navigate = useNavigate();
29
- const segmentProgress = useAppStore((s) => s.segmentProgress);
30
- const segmentFilename = useAppStore((s) => s.segmentFilename);
31
- const storedPreviewImage = useAppStore((s) => s.previewImage);
32
- const sessionHistory = useHistoryStore((s) => s.sessionHistory);
33
- const removeFromHistory = useHistoryStore((s) => s.removeFromHistory);
34
- const userId = useHistoryStore((s) => s.userId);
35
-
36
- const handleDeleteSession = useCallback(
37
- (e: React.MouseEvent, filename: string) => {
38
- e.stopPropagation();
39
- removeFromHistory(filename);
40
- deleteSessionFromBackend(userId, filename);
41
- },
42
- [removeFromHistory, userId],
43
- );
44
- const { count: activeSessions } = useActiveSessions();
45
- const {
46
- isDragging,
47
- previewImage,
48
- uploadMessage,
49
- isUploading,
50
- fileInputRef,
51
- handleDragOver,
52
- handleDragLeave,
53
- handleDrop,
54
- handleFileChange,
55
- handleDemoRoomSelect,
56
- triggerFileInput,
57
- clearPreviewImage,
58
- } = useRoomSetup();
59
-
60
- const habitacionesFiltradas =
61
- categoriaActiva === "todos"
62
- ? habitaciones
63
- : habitaciones.filter((h) => h.category === categoriaActiva);
64
-
65
- return (
66
- <div className="min-h-screen bg-[#f4f8ff] font-sans text-[#333333]">
67
- <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-8 relative">
68
- {isUploading && (
69
- <div className="fixed inset-0 bg-white/80 backdrop-blur-sm z-50 flex flex-col items-center justify-center gap-4 px-4">
70
- <p className="text-[#0047AB] font-semibold text-lg text-center">
71
- Analizando imagen con IA...
72
- </p>
73
- <div className="w-full max-w-xs sm:w-72 bg-gray-200 rounded-full h-3 overflow-hidden">
74
- <div
75
- className="bg-[#0047AB] h-3 rounded-full transition-all duration-500"
76
- style={{ width: `${segmentProgress}%` }}
77
- />
78
- </div>
79
- <p className="text-sm text-gray-500">{uploadMessage}</p>
80
- </div>
81
- )}
82
-
83
- {/* Badge de sesiones activas */}
84
- {activeSessions > 0 && (
85
- <div className="mb-4 flex justify-end">
86
- <div className="flex items-center gap-2 bg-white border border-[#dbe7ff] rounded-full px-4 py-1.5 shadow-sm">
87
- <span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
88
- <Users className="w-4 h-4 text-[#0047AB]" />
89
- <span className="text-sm font-medium text-[#0047AB]">
90
- {activeSessions} usuario{activeSessions !== 1 ? "s" : ""} activo
91
- {activeSessions !== 1 ? "s" : ""}
92
- </span>
93
- </div>
94
- </div>
95
- )}
96
-
97
- {/* SECCIÓN SUPERIOR: Sube tu foto */}
98
- <section className="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-12 mb-10 sm:mb-16 lg:mb-20">
99
- {/* Columna Izquierda: Textos y Botones */}
100
- <div className="flex flex-col justify-center">
101
- <h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-[#333333] mb-4 sm:mb-6 lg:mb-8">
102
- Ver los productos en su cuarto
103
- </h1>
104
-
105
- <ul className="space-y-4 sm:space-y-6 mb-6 sm:mb-8 lg:mb-10">
106
- <li className="flex items-center gap-3 text-base sm:text-lg font-medium text-[#333333]">
107
- <Camera className="w-5 h-5 sm:w-6 sm:h-6 text-[#0047AB] shrink-0" />
108
- Sube una foto de tu habitación
109
- </li>
110
- <li className="flex items-center gap-3 text-base sm:text-lg font-medium text-[#333333]">
111
- <Package className="w-5 h-5 sm:w-6 sm:h-6 text-[#0047AB] shrink-0" />
112
- Prueba nuestros productos
113
- </li>
114
- </ul>
115
-
116
- <div className="w-full flex flex-col gap-3">
117
- <button
118
- onClick={triggerFileInput}
119
- 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"
120
- >
121
- <CameraIcon
122
- className="w-5 h-5 sm:w-6 sm:h-6"
123
- strokeWidth={2.5}
124
- />
125
- Sube tu foto
126
- </button>
127
-
128
- {segmentFilename && !isUploading && (
129
- <button
130
- onClick={() =>
131
- navigate("/visualizer", {
132
- state: {
133
- previewImage:
134
- storedPreviewImage ??
135
- `${API_BASE}/seg/image/${segmentFilename}`,
136
- },
137
- })
138
- }
139
- 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"
140
- >
141
- Continuar al visualizador
142
- <ArrowRight
143
- className="w-5 h-5 sm:w-6 sm:h-6"
144
- strokeWidth={2.5}
145
- />
146
- </button>
147
- )}
148
- </div>
149
- {uploadMessage && (
150
- <p className="text-sm text-[#707070] mt-3">{uploadMessage}</p>
151
- )}
152
- </div>
153
-
154
- {/* Columna Derecha: Dropzone de Imagen */}
155
- <div
156
- 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 ${
157
- isDragging
158
- ? "border-[#0047AB] bg-[#eaf1ff]"
159
- : "border-[#dbe7ff] bg-[#f4f8ff] hover:bg-[#eef4ff]"
160
- }`}
161
- onDragOver={handleDragOver}
162
- onDragLeave={handleDragLeave}
163
- onDrop={handleDrop}
164
- >
165
- {previewImage ? (
166
- <div className="relative w-full h-full group">
167
- <img
168
- src={previewImage}
169
- alt="Vista previa"
170
- className="w-full h-full object-cover"
171
- />
172
- <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center backdrop-blur-sm">
173
- <button
174
- onClick={clearPreviewImage}
175
- 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"
176
- >
177
- <X className="w-4 h-4" /> Eliminar foto
178
- </button>
179
- </div>
180
- </div>
181
- ) : (
182
- <>
183
- <input
184
- type="file"
185
- accept="image/*"
186
- onChange={handleFileChange}
187
- ref={fileInputRef}
188
- className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
189
- />
190
- <div className="flex flex-col items-center pointer-events-none text-center p-6">
191
- <div
192
- 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 ${
193
- isDragging
194
- ? "bg-[#0047AB]/20 text-[#0047AB]"
195
- : "bg-[#eaf1ff] text-[#0047AB]"
196
- }`}
197
- >
198
- <UploadCloud className="w-7 h-7 sm:w-8 sm:h-8" />
199
- </div>
200
- <h3 className="text-lg sm:text-xl font-bold text-[#333333] mb-2">
201
- {isDragging
202
- ? "Suelta la imagen aquí"
203
- : "Arrastra tu foto aquí"}
204
- </h3>
205
- <p className="text-[#707070] text-sm sm:text-base">
206
- o haz clic para explorar en tu dispositivo
207
- </p>
208
- <p className="text-[#707070] text-xs mt-3 sm:mt-4">
209
- Formatos soportados: JPG, PNG
210
- </p>
211
- </div>
212
- </>
213
- )}
214
- </div>
215
- </section>
216
-
217
- {/* SECCIÓN HISTORIAL DE SESIÓN */}
218
- {sessionHistory.length > 0 && (
219
- <section className="mb-10 sm:mb-14">
220
- <h2 className="text-xl sm:text-2xl font-bold text-[#333333] mb-4 sm:mb-6">
221
- Tus espacios recientes
222
- </h2>
223
- <div
224
- className="flex gap-4 overflow-x-auto pb-2"
225
- style={{ scrollbarWidth: "thin" }}
226
- >
227
- {sessionHistory.map((item: HistoryItem) => (
228
- <div
229
- key={item.filename}
230
- role="button"
231
- tabIndex={0}
232
- onClick={() =>
233
- navigate("/visualizer", {
234
- state: {
235
- previewImage: item.previewUrl,
236
- filename: item.filename,
237
- maskCount: item.maskCount,
238
- },
239
- })
240
- }
241
- onKeyDown={(e) => {
242
- if (e.key === "Enter" || e.key === " ") {
243
- navigate("/visualizer", {
244
- state: {
245
- previewImage: item.previewUrl,
246
- filename: item.filename,
247
- maskCount: item.maskCount,
248
- },
249
- });
250
- }
251
- }}
252
- className={`flex-shrink-0 group relative rounded-2xl overflow-hidden border-2 transition-all duration-200 ${
253
- segmentFilename === item.filename
254
- ? "border-[#0047AB] shadow-lg shadow-[#0047AB]/20"
255
- : "border-[#dbe7ff] hover:border-[#0047AB]"
256
- }`}
257
- style={{ width: 160, height: 120 }}
258
- >
259
- <img
260
- src={item.previewUrl}
261
- alt="Habitación previa"
262
- className="w-full h-full object-cover"
263
- />
264
- {/* Overlay con hora */}
265
- <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent flex flex-col justify-end p-2">
266
- <p className="text-white/80 text-[10px]">
267
- {new Date(item.uploadedAt).toLocaleDateString([], {
268
- day: "2-digit",
269
- month: "short",
270
- })}{" "}
271
- {new Date(item.uploadedAt).toLocaleTimeString([], {
272
- hour: "2-digit",
273
- minute: "2-digit",
274
- })}
275
- </p>
276
- </div>
277
- {/* Badge "activa" o botón eliminar */}
278
- {segmentFilename === item.filename ? (
279
- <div className="absolute top-2 right-2 bg-[#0047AB] text-white text-[9px] font-bold px-1.5 py-0.5 rounded-full">
280
- Activa
281
- </div>
282
- ) : (
283
- <button
284
- onClick={(e) => handleDeleteSession(e, item.filename)}
285
- 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"
286
- >
287
- <X className="w-3 h-3" />
288
- </button>
289
- )}
290
- {/* Hover: continuar */}
291
- <div className="absolute inset-0 bg-[#0047AB]/80 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
292
- <span className="text-white text-xs font-bold flex items-center gap-1">
293
- Continuar <ArrowRight className="w-3.5 h-3.5" />
294
- </span>
295
- </div>
296
- </div>
297
- ))}
298
- </div>
299
- </section>
300
- )}
301
-
302
- {/* SECCIÓN INFERIOR: Habitaciones de demostración */}
303
- <section>
304
- <h2 className="text-xl sm:text-2xl font-bold text-[#0047AB] mb-4 sm:mb-6">
305
- ¿No tienes una foto? Prueba nuestras habitaciones de demostración
306
- </h2>
307
-
308
- {/* Filtros */}
309
- <div className="flex flex-wrap gap-2 mb-6 sm:mb-8">
310
- {categorias.map((cat) => (
311
- <FilterButton
312
- key={cat.id}
313
- category={cat}
314
- active={categoriaActiva === cat.id}
315
- onSelect={setCategoriaActiva}
316
- />
317
- ))}
318
- </div>
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, { useHistoryStore } from "../../store/useAppStore";
11
- import { uploadRoomImage, buildApiUrl } from "../../api/client";
 
 
 
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 setUploadedFile = useAppStore((s) => s.setUploadedFile);
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("Imagen lista — selecciona un producto para aplicar");
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
- // Ahora el flujo es: usuario sube imagen → selecciona producto → backend /api/generate-image
76
- // Navegamos al visualizador con la preview local y el usuario selecciona producto allí.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  navigate("/visualizer", {
78
- state: { previewImage: objectUrl },
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
- maskCount?: number;
45
- }
46
-
47
-
48
- export function RoomPreviewPanel({
49
- previewImage,
50
- offset,
51
- zoom,
52
- imageSize,
53
- wrapperRef,
54
- canvasRef,
55
- selectedProduct,
56
- selectedMasks,
57
- hoveredMask,
58
- segmentMeta,
59
- isApplying,
60
- onBack,
61
- onPointerDown,
62
- onPointerMove,
63
- onPointerUp,
64
- updateImageSize,
65
- onCanvasMouseMove,
66
- onCanvasMouseLeave,
67
- onCanvasClick,
68
- onApplyTexture,
69
- onReset,
70
- onDownload,
71
- onShare,
72
- maskCount = 0,
73
- }: RoomPreviewPanelProps) {
74
- const canApply = (selectedMasks.size > 0 || maskCount === 0) && selectedProduct != null;
75
-
76
-
77
- const getLabel = (index: number) =>
78
- segmentMeta.get(index)?.label ?? `Zona ${index}`;
79
-
80
- return (
81
- <div className="w-full h-full bg-white overflow-hidden">
82
- <div className="relative h-full overflow-hidden lg:rounded-lg bg-[#f4f8ff]">
83
-
84
- {/* ── Mobile top bar ───────────────────────────────────────── */}
85
- {/* pr-12 reserva espacio a la derecha para el botón .hr-close del padre */}
86
- <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">
87
- <button
88
- onClick={onBack}
89
- className="p-2 rounded-full hover:bg-gray-100 transition-colors"
90
- >
91
- <ArrowLeft className="h-5 w-5 text-[#333]" />
92
- </button>
93
- <div className="flex items-center gap-1">
94
- <button
95
- onClick={onShare}
96
- className="p-2 rounded-full hover:bg-[#eaf1ff] transition-colors"
97
- >
98
- <Share2 className="h-5 w-5 text-[#0047AB]" />
99
- </button>
100
- <button
101
- onClick={onDownload}
102
- className="p-2 rounded-full hover:bg-[#eaf1ff] transition-colors"
103
- >
104
- <Download className="h-5 w-5 text-[#0047AB]" />
105
- </button>
106
- </div>
107
- </div>
108
-
109
- {/* ── Desktop top bar ──────────────────────────────────────── */}
110
- <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">
111
- <button
112
- onClick={onBack}
113
- 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"
114
- >
115
- <ArrowLeft className="h-4 w-4" /> Cambiar de Habitación
116
- </button>
117
- <span className="text-gray-400">|</span>
118
- <button
119
- onClick={onShare}
120
- 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"
121
- >
122
- <Share2 className="h-4 w-4 text-[#0047AB]" />
123
- Compartir
124
- </button>
125
- <button
126
- onClick={onDownload}
127
- 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"
128
- >
129
- <Download className="h-4 w-4 text-[#0047AB]" /> Descargar
130
- </button>
131
- <a
132
- href="https://nauffargermany.com/gt/sucursales-2/"
133
- target="_blank"
134
- rel="noopener noreferrer"
135
- 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"
136
- >
137
- <MapPin className="h-4 w-4 text-[#0047AB]" /> Encuentra tu tienda
138
- </a>
139
- {selectedProduct?.detailUrl ? (
140
- <a
141
- href={selectedProduct.detailUrl}
142
- target="_blank"
143
- rel="noopener noreferrer"
144
- 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"
145
- >
146
- <ShoppingCart className="h-4 w-4 text-[#0047AB]" /> Ir a la página del producto
147
- </a>
148
- ) : (
149
- <button
150
- disabled
151
- 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"
152
- >
153
- <ShoppingCart className="h-4 w-4 text-gray-300" /> Ir a la página del producto
154
- </button>
155
- )}
156
- </div>
157
-
158
- {/* ── Área de imagen + canvas ──────────────────────────────── */}
159
- <div
160
- ref={wrapperRef}
161
- className="absolute inset-x-0 top-12 lg:top-16 bottom-12 lg:bottom-16 flex items-center justify-center bg-[#edf4ff] overflow-hidden"
162
- onPointerDown={onPointerDown}
163
- onPointerMove={onPointerMove}
164
- onPointerUp={onPointerUp}
165
- >
166
- {previewImage ? (
167
- <div
168
- style={{
169
- transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
170
- transformOrigin: "center center",
171
- position: "relative",
172
- display: "inline-flex",
173
- lineHeight: 0,
174
- ...(imageSize.width > 0
175
- ? { width: imageSize.width, height: imageSize.height }
176
- : {}),
177
- }}
178
- >
179
- <img
180
- src={previewImage}
181
- alt="Vista previa de la habitación"
182
- draggable={false}
183
- onDragStart={(e) => e.preventDefault()}
184
- onLoad={(event: SyntheticEvent<HTMLImageElement>) =>
185
- updateImageSize(event.currentTarget)
186
- }
187
- style={{
188
- display: "block",
189
- width: imageSize.width > 0 ? "100%" : "auto",
190
- height: imageSize.height > 0 ? "100%" : "auto",
191
- maxWidth: imageSize.width > 0 ? "none" : "100%",
192
- maxHeight: imageSize.height > 0 ? "none" : "100%",
193
- objectFit: "contain",
194
- }}
195
- />
196
- <canvas
197
- ref={canvasRef}
198
- style={{
199
- position: "absolute",
200
- inset: 0,
201
- width: "100%",
202
- height: "100%",
203
- cursor: "crosshair",
204
- }}
205
- onMouseMove={onCanvasMouseMove}
206
- onMouseLeave={onCanvasMouseLeave}
207
- onClick={onCanvasClick}
208
- />
209
- </div>
210
- ) : (
211
- <div className="flex h-full w-full items-center justify-center text-[#707070] text-sm px-6 text-center">
212
- No hay vista previa disponible aún.
213
- </div>
214
- )}
215
- </div>
216
-
217
- {/* ── Hint de selección ────────────────────────────────────── */}
218
- {previewImage && selectedMasks.size === 0 && (
219
- <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">
220
- {maskCount > 0 ? (
221
- hoveredMask > 0
222
- ? `${getLabel(hoveredMask)} haz clic para seleccionar`
223
- : "Haz clic sobre una zona de la imagen para seleccionarla"
224
- ) : (
225
- "Selecciona un producto de la lista para visualizarlo con IA"
226
- )}
227
- </div>
228
- )}
229
-
230
-
231
- {/* ── Mobile bottom bar ────────────────────────────────────── */}
232
- <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">
233
- {selectedProduct && (
234
- <div className="flex items-center gap-2 min-w-0 flex-1">
235
- <img
236
- src={selectedProduct.image}
237
- alt={selectedProduct.name}
238
- className="w-8 h-8 object-cover rounded-md border border-gray-200 shrink-0"
239
- />
240
- <div className="min-w-0">
241
- <p className="text-[10px] text-[#707070] truncate">{selectedProduct.brand}</p>
242
- <p className="text-xs font-semibold text-[#333] truncate leading-tight">{selectedProduct.name}</p>
243
- </div>
244
- </div>
245
- )}
246
- {!selectedProduct && selectedMasks.size > 0 && (
247
- <p className="text-xs text-[#0047AB] font-medium truncate flex-1">
248
- {[...selectedMasks].map(getLabel).join(", ")}
249
- </p>
250
- )}
251
- {!selectedProduct && selectedMasks.size === 0 && <div className="flex-1" />}
252
-
253
- <div className="flex items-center gap-1 ml-auto shrink-0">
254
- {canApply && (
255
- <button
256
- onClick={onApplyTexture}
257
- disabled={isApplying}
258
- 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"
259
- >
260
- {isApplying ? (
261
- <Loader2 className="h-3 w-3 animate-spin" />
262
- ) : (
263
- <Paintbrush className="h-3 w-3" />
264
- )}
265
- {isApplying ? "Aplicando..." : "Aplicar"}
266
- </button>
267
- )}
268
- <button
269
- onClick={onReset}
270
- className="pointer-events-auto p-2 rounded-full hover:bg-[#eaf1ff] transition-colors"
271
- >
272
- <RefreshCw className="h-4 w-4 text-[#0047AB]" />
273
- </button>
274
- </div>
275
- </div>
276
-
277
- {/* ── Desktop bottom bar ───────────────────────────────────── */}
278
- <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">
279
- {selectedProduct && (
280
- <div className="pointer-events-none flex items-center gap-3">
281
- <img
282
- src={selectedProduct.image}
283
- alt={selectedProduct.name}
284
- className="w-10 h-10 object-cover rounded-md border border-gray-200"
285
- />
286
- <div>
287
- <p className="text-[#707070] text-xs">{selectedProduct.brand}</p>
288
- <p className="font-semibold text-[#333333] text-sm leading-tight">
289
- {selectedProduct.name}
290
- </p>
291
- </div>
292
- </div>
293
- )}
294
-
295
- {selectedMasks.size > 0 && (
296
- <p className="text-xs text-[#0047AB] font-medium truncate max-w-[260px]">
297
- {[...selectedMasks].map(getLabel).join(", ")}
298
- </p>
299
- )}
300
-
301
- <div className="flex-1" />
302
-
303
- {canApply && (
304
- <button
305
- onClick={onApplyTexture}
306
- disabled={isApplying}
307
- 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"
308
- >
309
- {isApplying ? (
310
- <Loader2 className="h-4 w-4 animate-spin" />
311
- ) : (
312
- <Paintbrush className="h-4 w-4" />
313
- )}
314
- {isApplying ? "Aplicando..." : "Aplicar textura"}
315
- </button>
316
- )}
317
-
318
- <button
319
- onClick={onReset}
320
- 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"
321
- >
322
- <span className="inline-flex items-center justify-center rounded-full bg-[#eaf1ff] p-2">
323
- <RefreshCw className="h-4 w-4 text-[#0047AB]" />
324
- </span>
325
- Reiniciar
326
- </button>
327
- <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">
328
- <span className="inline-flex items-center justify-center rounded-full bg-[#eaf1ff] p-2">
329
- <RotateCcw className="h-4 w-4 text-[#0047AB]" />
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 { LayoutGrid, Search, SlidersHorizontal, Menu, X } from "lucide-react";
12
- import Swal from "sweetalert2";
13
- import {
14
- WhatsappShareButton,
15
- WhatsappIcon,
16
- TelegramShareButton,
17
- TelegramIcon,
18
- TwitterShareButton,
19
- XIcon,
20
- FacebookShareButton,
21
- FacebookIcon,
22
- EmailShareButton,
23
- EmailIcon,
24
- } from "react-share";
25
- import { ProductGroupCard, IndividualProductCard } from "./ProductCards";
26
- import { useRoomVisualizer } from "./roomVisualizerHooks";
27
- import { useCatalogProducts } from "./useCatalogProducts";
28
- import { RoomPreviewPanel } from "./RoomPreviewPanel";
29
- import useAppStore from "../../store/useAppStore";
30
- import { API_BASE } from "../../api/client";
31
- // No segmentation canvas: we remove mask-based flows and use defaults
32
- import { useApplyTexture } from "../../hooks/useApplyTexture";
33
- import type { SegmentMeta } from "../../hooks/useSegmentCanvas";
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
- (store) => store.setAccumulatedFilename,
63
- );
64
- const setSegmentResult = useAppStore((store) => store.setSegmentResult);
65
- const setPreviewImage = useAppStore((store) => store.setPreviewImage);
66
-
67
- // Restaurar segmentFilename y previewImage cuando se abre una sesión del historial
68
- useEffect(() => {
69
- if (state?.filename && state.filename !== segmentFilename) {
70
- setSegmentResult(state.filename, state.maskCount ?? 0);
71
- setAccumulatedFilename(null); // limpiar ediciones de sesión anterior
72
- }
73
- if (state?.previewImage) {
74
- setPreviewImage(state.previewImage);
75
- }
76
- // Solo al montar — state no cambia tras la navegación
77
- // eslint-disable-next-line react-hooks/exhaustive-deps
78
- }, []);
79
-
80
- const [currentPreviewImage, setCurrentPreviewImage] = useState<string | null>(
81
- () => {
82
- if (accumulatedFilename)
83
- return `${API_BASE}/seg/image/${accumulatedFilename}`;
84
- return state?.previewImage ?? storedPreviewImage ?? null;
85
- },
86
- );
87
-
88
- const [zoom, setZoom] = useState(1);
89
- const [offset, setOffset] = useState({ x: 0, y: 0 });
90
- const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(
91
- null,
92
- );
93
- const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
94
- const wrapperRef = useRef<HTMLDivElement>(null);
95
-
96
- const { products, categories, loading, error } = useCatalogProducts();
97
-
98
- // null = sin interacción (primera categoría abierta por defecto)
99
- // Set vacío = usuario cerró todo deliberadamente
100
- const [openCategoryIds, setOpenCategoryIds] = useState<Set<string> | null>(
101
- null,
102
- );
103
-
104
- const isCategoryOpen = useCallback(
105
- (id: string) => {
106
- if (openCategoryIds === null) {
107
- return categories.length > 0 && id === categories[0].id;
108
- }
109
- return openCategoryIds.has(id);
110
- },
111
- [openCategoryIds, categories],
112
- );
113
-
114
- const toggleCategory = useCallback(
115
- (id: string) => {
116
- setOpenCategoryIds((prev) => {
117
- const base =
118
- prev === null
119
- ? categories.length > 0
120
- ? new Set([categories[0].id])
121
- : new Set<string>()
122
- : new Set(prev);
123
- if (base.has(id)) base.delete(id);
124
- else base.add(id);
125
- return base;
126
- });
127
- },
128
- [categories],
129
- );
130
-
131
- const {
132
- viewMode,
133
- showGrid,
134
- showList,
135
- openProductId,
136
- handleSelectProduct,
137
- selectedProduct,
138
- isSearchOpen,
139
- setIsSearchOpen,
140
- searchQuery,
141
- setSearchQuery,
142
- closeSearch,
143
- filteredProducts,
144
- chunkArray,
145
- } = useRoomVisualizer(products);
146
-
147
- useEffect(() => {
148
- showList();
149
- }, [showList]);
150
-
151
- // Masks/segmentation removed: provide safe defaults/noops so panels keep working
152
- const canvasRef = useRef<HTMLCanvasElement | null>(null);
153
- const hoveredMask = -1;
154
- const selectedMasks = new Set<number>();
155
- const segmentMeta = new Map<number, SegmentMeta>();
156
- const handleCanvasMouseMove = () => {};
157
- const handleCanvasMouseLeave = () => {};
158
- const handleCanvasClick = () => {};
159
-
160
- const {
161
- applyTexture,
162
- applyTextureAI,
163
- applyTextureOpenAI,
164
- isApplying,
165
- resetResult,
166
- } = useApplyTexture();
167
- const uploadedFile = useAppStore((s) => s.uploadedFile);
168
-
169
- const applyTextureWith = useCallback(
170
- async (texturePath: string) => {
171
- if (isApplying) {
172
- Swal.fire(
173
- "Espera",
174
- "Ya hay una operación en curso. Espera a que termine.",
175
- "info",
176
- );
177
- return;
178
- }
179
- // Si el usuario subió una imagen en esta sesión, preferimos usar el endpoint OpenAI
180
- if (uploadedFile) {
181
- try {
182
- // If the image was already uploaded to server, prefer passing the server filename (avoid sending generated previews)
183
- const serverUrl = state?.previewImage ?? storedPreviewImage ?? null;
184
- let source: File | string = uploadedFile;
185
- // If preview points to uploads path, extract filename and send it instead
186
- if (
187
- typeof serverUrl === "string" &&
188
- serverUrl.includes("/uploads/")
189
- ) {
190
- // serverUrl may be e.g. "http://localhost:8000/uploads/<filename>" or "/uploads/<filename>"
191
- const idx = serverUrl.lastIndexOf("/uploads/");
192
- const filename = serverUrl.substring(idx + "/uploads/".length);
193
- source = filename;
194
- }
195
- const data = await applyTextureOpenAI(source, texturePath);
196
- if (data?.url) {
197
- setCurrentPreviewImage(data.url);
198
- }
199
- } catch (err) {
200
- Swal.fire(
201
- "Error",
202
- err instanceof Error ? err.message : "Error al procesar",
203
- "error",
204
- );
205
- }
206
- return;
207
- }
208
- // Si no hay imagen subida y no hay segmentFilename, no hay flujo sin máscaras disponible
209
- if (!segmentFilename) {
210
- Swal.fire(
211
- "Atención",
212
- "Sube primero una imagen para aplicar el producto.",
213
- "info",
214
- );
215
- return;
216
- }
217
- const baseFilename = accumulatedFilename ?? segmentFilename;
218
- try {
219
- // Si no hay máscaras seleccionadas (o no existen en el flujo simplificado), usamos IA
220
- if (selectedMasks.size === 0) {
221
- const data = await applyTextureAI(
222
- baseFilename,
223
- texturePath,
224
- segmentFilename,
225
- );
226
-
227
- let resultData = data;
228
- // Si es un job asíncrono, poll para esperar el resultado
229
- if (data.job_id) {
230
- const poll = async () => {
231
- while (true) {
232
- const res = await fetch(`${API_BASE}/seg/jobs/${data.job_id}`);
233
- const job = await res.json();
234
- if (job.status === "done") return job.result;
235
- if (job.status === "failed")
236
- throw new Error(job.message || "AI failed");
237
- await new Promise((r) => setTimeout(r, 2000));
238
- }
239
- };
240
- resultData = await poll();
241
- }
242
-
243
- if (resultData?.url) {
244
- setCurrentPreviewImage(
245
- `${API_BASE}${resultData.url}?t=${Date.now()}`,
246
- );
247
- setAccumulatedFilename(resultData.filename);
248
- }
249
- return;
250
- }
251
-
252
- // Flujo tradicional (si hubiera máscaras)
253
- const data = await applyTexture(
254
- baseFilename,
255
- [...selectedMasks],
256
- texturePath,
257
- segmentFilename,
258
- );
259
- if (data?.output_url) {
260
- setCurrentPreviewImage(
261
- `${API_BASE}${data.output_url}?t=${Date.now()}`,
262
- );
263
- setAccumulatedFilename(data.output_filename);
264
- }
265
- } catch (err) {
266
- Swal.fire(
267
- "Error",
268
- err instanceof Error ? err.message : "Error al procesar",
269
- "error",
270
- );
271
- }
272
- },
273
- [
274
- applyTexture,
275
- applyTextureAI,
276
- segmentFilename,
277
- selectedMasks,
278
- accumulatedFilename,
279
- setAccumulatedFilename,
280
- ],
281
- );
282
-
283
- const handleApplyTexture = useCallback(async () => {
284
- if (selectedProduct) await applyTextureWith(selectedProduct.ref);
285
- }, [applyTextureWith, selectedProduct]);
286
-
287
- const handleProductSelect = useCallback(
288
- async (id: string | number | null) => {
289
- handleSelectProduct(id);
290
- if (!id) return;
291
- const product = products.find((p) => p.id === id);
292
- if (!product) return;
293
- // If user uploaded a file, use OpenAI flow; otherwise require existing segmentFilename
294
- if (uploadedFile) {
295
- await applyTextureWith(product.ref);
296
- return;
297
- }
298
- if (!segmentFilename) {
299
- Swal.fire(
300
- "Atención",
301
- "Sube primero una imagen para aplicar el producto.",
302
- "info",
303
- );
304
- return;
305
- }
306
- await applyTextureWith(product.ref);
307
- },
308
- [handleSelectProduct, segmentFilename, products, applyTextureWith],
309
- );
310
-
311
- const handleReset = useCallback(() => {
312
- const original = state?.previewImage ?? storedPreviewImage ?? null;
313
- setCurrentPreviewImage(original);
314
- setAccumulatedFilename(null);
315
- resetResult();
316
- }, [state, storedPreviewImage, setAccumulatedFilename, resetResult]);
317
-
318
- const handleDownload = useCallback(async () => {
319
- if (!currentPreviewImage) return;
320
- const response = await fetch(currentPreviewImage);
321
- const blob = await response.blob();
322
- const url = URL.createObjectURL(blob);
323
- const a = document.createElement("a");
324
- a.href = url;
325
- a.download = `hyper-reality-${Date.now()}.jpg`;
326
- document.body.appendChild(a);
327
- a.click();
328
- document.body.removeChild(a);
329
- URL.revokeObjectURL(url);
330
- }, [currentPreviewImage]);
331
-
332
- const handleShare = useCallback(async (): Promise<void> => {
333
- let shareUrl = window.location.href;
334
- const outputFilename = accumulatedFilename;
335
- if (outputFilename) {
336
- try {
337
- const res = await fetch(`${API_BASE}/api/share`, {
338
- method: "POST",
339
- headers: { "Content-Type": "application/json" },
340
- body: JSON.stringify({
341
- output_filename: outputFilename,
342
- segment_filename: segmentFilename,
343
- }),
344
- });
345
- if (res.ok) {
346
- const data = (await res.json()) as { share_id: string };
347
- shareUrl = `${window.location.origin}/app/share/${data.share_id}`;
348
- }
349
- } catch {
350
- // fallback to current URL
351
- }
352
- }
353
-
354
- const title = "My design with Hyper Reality Visualizer";
355
- let reactRoot: ReturnType<typeof createRoot> | null = null;
356
-
357
- await Swal.fire({
358
- title: "Compartir diseño",
359
- html: `
360
- <div style="text-align:left">
361
- <p style="font-size:13px;color:#666;margin-bottom:8px">Enlace de tu diseño:</p>
362
- <div style="display:flex;gap:8px;align-items:center;margin-bottom:20px">
363
- <input id="swal-share-url" readonly value="${shareUrl}"
364
- style="flex:1;padding:8px 12px;border:1px solid #ddd;border-radius:8px;font-size:12px;background:#f9f9f9;outline:none;color:#333;" />
365
- <button id="swal-copy-btn"
366
- style="padding:8px 16px;background:#0047AB;color:white;border:none;border-radius:8px;cursor:pointer;font-size:13px;white-space:nowrap;font-weight:600;">
367
- Copiar
368
- </button>
369
- </div>
370
- <p style="font-size:13px;color:#666;margin-bottom:12px">Compartir en:</p>
371
- <div id="swal-share-buttons" style="display:flex;gap:12px;flex-wrap:wrap;"></div>
372
- </div>
373
- `,
374
- showConfirmButton: false,
375
- showCloseButton: true,
376
- width: 500,
377
- didOpen: () => {
378
- const copyBtn = document.getElementById("swal-copy-btn");
379
- copyBtn?.addEventListener("click", async () => {
380
- await navigator.clipboard.writeText(shareUrl).catch(() => {});
381
- if (copyBtn) {
382
- copyBtn.textContent = "¡Copiado!";
383
- copyBtn.style.background = "#16a34a";
384
- }
385
- setTimeout(() => {
386
- if (copyBtn) {
387
- copyBtn.textContent = "Copiar";
388
- copyBtn.style.background = "#0047AB";
389
- }
390
- }, 2000);
391
- });
392
- const container = document.getElementById("swal-share-buttons");
393
- if (container) {
394
- reactRoot = createRoot(container);
395
- reactRoot.render(
396
- <>
397
- <WhatsappShareButton url={shareUrl} title={title}>
398
- <WhatsappIcon size={48} round />
399
- </WhatsappShareButton>
400
- <TelegramShareButton url={shareUrl} title={title}>
401
- <TelegramIcon size={48} round />
402
- </TelegramShareButton>
403
- <TwitterShareButton url={shareUrl} title={title}>
404
- <XIcon size={48} round />
405
- </TwitterShareButton>
406
- <FacebookShareButton url={shareUrl}>
407
- <FacebookIcon size={48} round />
408
- </FacebookShareButton>
409
- <EmailShareButton
410
- url={shareUrl}
411
- subject={title}
412
- body="Mira mi diseño de habitación:"
413
- >
414
- <EmailIcon size={48} round />
415
- </EmailShareButton>
416
- </>,
417
- );
418
- }
419
- },
420
- willClose: () => {
421
- reactRoot?.unmount();
422
- },
423
- });
424
- }, [segmentFilename, accumulatedFilename]);
425
-
426
- const clampOffset = useCallback(
427
- (x: number, y: number, zoomValue: number) => {
428
- const wrapper = wrapperRef.current;
429
- if (!wrapper || imageSize.width === 0 || imageSize.height === 0)
430
- return { x, y };
431
- const containerRect = wrapper.getBoundingClientRect();
432
- const scaledWidth = imageSize.width * zoomValue;
433
- const scaledHeight = imageSize.height * zoomValue;
434
- const maxX = Math.max(0, (scaledWidth - containerRect.width) / 2);
435
- const maxY = Math.max(0, (scaledHeight - containerRect.height) / 2);
436
- return {
437
- x: Math.max(-maxX, Math.min(maxX, x)),
438
- y: Math.max(-maxY, Math.min(maxY, y)),
439
- };
440
- },
441
- [imageSize],
442
- );
443
-
444
- const updateImageSize = useCallback(
445
- (img: HTMLImageElement) => {
446
- const wrapper = wrapperRef.current;
447
- if (!wrapper) return;
448
- const containerRect = wrapper.getBoundingClientRect();
449
- const naturalRatio = img.naturalWidth / img.naturalHeight;
450
- const containerRatio = containerRect.width / containerRect.height;
451
- const width =
452
- naturalRatio > containerRatio
453
- ? containerRect.width
454
- : containerRect.height * naturalRatio;
455
- const height =
456
- naturalRatio > containerRatio
457
- ? containerRect.width / naturalRatio
458
- : containerRect.height;
459
- setImageSize({ width, height });
460
- setOffset((current) => clampOffset(current.x, current.y, zoom));
461
- },
462
- [clampOffset, zoom],
463
- );
464
-
465
- const handleWheel = useCallback(
466
- (event: WheelEvent) => {
467
- event.preventDefault();
468
- setZoom((currentZoom) => {
469
- const next = Math.min(
470
- 3,
471
- Math.max(1, currentZoom - event.deltaY * 0.0015),
472
- );
473
- setOffset((current) => clampOffset(current.x, current.y, next));
474
- return next;
475
- });
476
- },
477
- [clampOffset],
478
- );
479
-
480
- useEffect(() => {
481
- const el = wrapperRef.current;
482
- if (!el) return;
483
- el.addEventListener("wheel", handleWheel, { passive: false });
484
- return () => el.removeEventListener("wheel", handleWheel);
485
- }, [handleWheel]);
486
-
487
- const handlePointerDown = useCallback(
488
- (event: PointerEvent<HTMLDivElement>) => {
489
- setDragStart({ x: event.clientX, y: event.clientY });
490
- },
491
- [],
492
- );
493
-
494
- const handlePointerMove = useCallback(
495
- (event: PointerEvent<HTMLDivElement>) => {
496
- if (!dragStart || zoom <= 1) return;
497
- const dx = event.clientX - dragStart.x;
498
- const dy = event.clientY - dragStart.y;
499
- setOffset((current) => clampOffset(current.x + dx, current.y + dy, zoom));
500
- setDragStart({ x: event.clientX, y: event.clientY });
501
- },
502
- [clampOffset, dragStart, zoom],
503
- );
504
-
505
- const handlePointerUp = useCallback(() => setDragStart(null), []);
506
-
507
- // Props compartidos entre mobile y desktop para RoomPreviewPanel
508
- const previewPanelProps = {
509
- previewImage: currentPreviewImage,
510
- offset,
511
- zoom,
512
- imageSize,
513
- wrapperRef,
514
- canvasRef,
515
- selectedProduct,
516
- selectedMasks,
517
- hoveredMask,
518
- segmentMeta,
519
- isApplying,
520
- onBack: () => navigate("/app"),
521
- onPointerDown: handlePointerDown,
522
- onPointerMove: handlePointerMove,
523
- onPointerUp: handlePointerUp,
524
- updateImageSize,
525
- onCanvasMouseMove: handleCanvasMouseMove,
526
- onCanvasMouseLeave: handleCanvasMouseLeave,
527
- onCanvasClick: handleCanvasClick,
528
- onApplyTexture: handleApplyTexture,
529
- onReset: handleReset,
530
- onDownload: handleDownload,
531
- onShare: handleShare,
532
- maskCount: state?.maskCount ?? 0,
533
- };
534
-
535
- // ── Mobile strip: thumbnails + búsqueda ──────────────────────────────────
536
- const MobileProductStrip = (
537
- <div
538
- style={{
539
- background: "#fff",
540
- borderTop: "1px solid #e5e7eb",
541
- flexShrink: 0,
542
- }}
543
- >
544
- {isSearchOpen && (
545
- <div style={{ padding: "8px 12px 4px" }}>
546
- <div style={{ position: "relative" }}>
547
- <Search
548
- style={{
549
- position: "absolute",
550
- left: 10,
551
- top: "50%",
552
- transform: "translateY(-50%)",
553
- width: 16,
554
- height: 16,
555
- color: "#9ca3af",
556
- }}
557
- />
558
- <input
559
- autoFocus
560
- type="text"
561
- placeholder="Buscar productos..."
562
- value={searchQuery}
563
- onChange={(e) => setSearchQuery(e.target.value)}
564
- style={{
565
- width: "100%",
566
- paddingLeft: 34,
567
- paddingRight: 32,
568
- paddingTop: 8,
569
- paddingBottom: 8,
570
- borderRadius: 8,
571
- border: "2px solid #0047AB",
572
- outline: "none",
573
- fontSize: 14,
574
- color: "#333",
575
- boxSizing: "border-box",
576
- }}
577
- />
578
- <button
579
- onClick={closeSearch}
580
- style={{
581
- position: "absolute",
582
- right: 8,
583
- top: "50%",
584
- transform: "translateY(-50%)",
585
- background: "none",
586
- border: "none",
587
- cursor: "pointer",
588
- padding: 4,
589
- }}
590
- >
591
- <X style={{ width: 14, height: 14, color: "#9ca3af" }} />
592
- </button>
593
- </div>
594
- </div>
595
- )}
596
-
597
- {/* Thumbnails con scroll horizontal */}
598
- <div
599
- style={{
600
- display: "flex",
601
- overflowX: "auto",
602
- gap: 8,
603
- padding: "8px 12px",
604
- scrollbarWidth: "none",
605
- }}
606
- >
607
- {loading ? (
608
- <div
609
- style={{
610
- display: "flex",
611
- alignItems: "center",
612
- justifyContent: "center",
613
- width: "100%",
614
- height: 64,
615
- fontSize: 12,
616
- color: "#9ca3af",
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
+ }