# widget-video

**La foto de tu producto se convierte, en segundos y en su sitio, en el video que el visitante imagina.**
Un widget embebible con una sola línea de script. Hace una pregunta definida por la tienda
(«¿Qué tipo de perro tienes?», «¿Con qué te comerías estas galletas?») y con la respuesta la propia foto de
la ficha cobra vida: lo que el visitante describió entra en escena e **interactúa con el producto** (el perro
lo muerde, la galleta se moja en el café) sin que cambie de forma, color ni material, y al final todo vuelve a
la foto. Sin recuadros ni avisos: la foto respira mientras se genera y el video se funde sobre ella.
Motor: **MiniMax H3 Max, imagen a video, en fal.ai** (cliente oficial `@fal-ai/client`, clave en `FAL_KEY`).

- URL pública: **https://widget.srv1820917.hstgr.cloud** · demos: [juguetes para perro](https://widget.srv1820917.hstgr.cloud/demo/perros) · [galletas](https://widget.srv1820917.hstgr.cloud/demo/galletas) · [métricas en vivo](https://widget.srv1820917.hstgr.cloud/metricas) · [instalar](https://widget.srv1820917.hstgr.cloud/instalar)
- Documentos: `DECISIONES.md` (cada decisión y su porqué) · `PRUEBAS.md` (puerta de entrada e iteración del prompt, con hojas de contacto)

---

## Arranque con un solo comando

```bash
cp .env.example .env        # pon FAL_KEY=... (https://fal.ai/dashboard/keys)
docker compose up -d --build
```

Eso levanta **un solo servicio** (`app`, Node 22 sin frameworks) detrás de **Caddy** y abre http://localhost.
Para HTTPS en producción basta con `SITIO=widget.tu-dominio.com` en `.env`: Caddy pide el certificado solo.
Si el 80/443 ya está ocupado (por ejemplo por otro proxy), `PUERTO_HTTP=8080 PUERTO_HTTPS=8443` en `.env`.

Sin Docker: `npm install` y `npm start` (Node ≥ 20.12; el servidor lee `.env` él mismo).

Variante para servidores que ya tienen Traefik en 80/443 (es el caso del VPS donde vive la URL pública):
`DOMINIO=widget.tu-dominio.com docker compose -f docker-compose.traefik.yml up -d --build`
(o `bash scripts/desplegar-vps.sh`, que sube el proyecto por ssh y hace lo mismo).

---

## Cómo funciona

```
ficha de producto ──<script widget.js data-tienda data-producto data-imagen>──▶ widget (Shadow DOM)
      │  la <img> del producto que YA está en la ficha se envuelve: será la pantalla del video
      │  GET /api/config  → pregunta, foto, respuestas que ya tienen video, límites restantes
      │  POST /api/generar {tienda, producto, respuesta}
      ▼
servidor Node ── sanea la respuesta (60 caracteres, lista blanca) ── normaliza → clave de caché
      ├─ ¿video en data/videos/<clave>.mp4?  →  200 {modo: "cache", url}          (0 generaciones)
      ├─ ¿misma clave ya en vuelo?           →  espera esa misma generación         (0 generaciones)
      ├─ ¿límite por IP/día o total?         →  200 {modo: "limite", url del video más visto del producto}
      └─ fal.subscribe("minimax/h3-max/image-to-video", {prompt de plantilla, image_url, end_image_url})
             → descarga el mp4 → data/videos/ + metadatos json → métricas → 200 {modo: "nueva", url}
widget: mientras espera, la foto «respira» (sin recuadro ni texto) · al primer fotograma decodificado
        cronometra clic → primer fotograma y lo envía a POST /api/latencia · el video se funde sobre la foto
        (empieza y termina en ella: end_image_url) · el siguiente video entra al acabar el bucle del anterior
```

### En su sitio y sin corte
El widget no abre un recuadro aparte: envuelve la `<img>` del producto que ya está en la ficha (`data-imagen`, o
la más cercana al `<script>`) en un marco con `overflow:hidden`, y superpone un `<video>` del mismo tamaño,
radio y `object-fit`, sincronizado con `ResizeObserver`. Mientras se genera, foto y video reciben una animación
de «respiración» (escala 1 → 1.035, 4.5 s; desactivada con `prefers-reduced-motion`). Cuando el primer fotograma
está decodificado se copia el `transform` actual de la foto al video, se retira la animación y se hace un fundido
de 0.7 s: como el video empieza en la propia foto (`image_url`) y termina en ella (`end_image_url`), no hay corte
ni al entrar ni al hacer bucle. Si ya había un video en pantalla, el nuevo espera a que el anterior llegue al
final de su bucle (máx. 6.5 s) y entra ahí, foto contra foto. Si la ficha no tiene imagen, el widget crea su
propio marco con la foto del producto y hace lo mismo.

### El texto del visitante nunca es prompt libre
`lib/sanear.js` deja solo letras, dígitos, espacio y `, . ' ’ - ¿ ? ¡ !`, máximo 60 caracteres. Ese texto
se inserta **entre comillas** en un hueco (`{respuesta}`) de la frase `escena` que la tienda define en
`tiendas/<id>.json`. Delante va un preámbulo fijo del servidor (cámara fija, encuadre fijo, producto intacto)
y detrás un cierre fijo. Cualquier intento de «instrucción» queda leído como una cita.

### Los prompts reales (versión 3: lo que entra en escena interactúa con el producto)
Preámbulo (fijo, `lib/plantillas.js`, `VERSION_BASE = 3`):

> Static locked-off camera on a tripod: no camera movement, no zoom, no pan, no reframing; the framing is identical from the first frame to the last. The **{producto}** from the photo is the hero of the scene and is really used and interacted with: it keeps its exact shape, colors, size, texture and material at all times (never deformed, never squashed, never melted, never replaced, never duplicated, never broken), and by the last frame it is back in exactly the same position as in the photo. Anything that enters adapts to the fixed framing and may be partially cropped by the frame edges.

Escena de la tienda de juguetes para perro (`tiendas/perros.json`):

> A dog of exactly this kind appears: "**{respuesta}**". It comes in from the side, sniffs the toy, grabs it in its mouth, shakes and chews it playfully with its tail wagging, then drops it back exactly where it was and looks at the camera happily. The toy stays rigid and whole in the dog's mouth.

Escena de la tienda de galletas (`tiendas/galletas.json`):

> This is served on the table next to the cookies: "**{respuesta}**". A hand picks the top cookie from the stack, dunks it into it, lifts it up dripping, and puts the cookie back on top of the stack exactly as it was; cozy, warm and appetizing. The cookies keep their exact shape and golden color.

Cierre (fijo): *Photorealistic, natural soft light, clean product advertisement, natural physical motion.*

Las versiones anteriores (v1 «nadie lo toca», v2 «encuadre fijo») están en `PRUEBAS.md`: la v3 nació de la
revisión de Joshi del 2026-09-06 («los elementos solo se añaden a la imagen, no interactúan con el producto»).
La regla ahora es: **el producto se usa de verdad (se muerde, se moja, se sostiene) sin cambiar de forma, color ni
material, y termina donde empezó**. Que termine donde empezó no lo garantiza el texto sino `end_image_url`.

`{producto}` es la `descripcion` del producto en inglés («bright orange rubber bone-shaped dog chew toy»).
Parámetros enviados a fal (leídos del OpenAPI del endpoint, no supuestos): `duration: 5`, `resolution: "768P"`,
`prompt_expansion_mode: "balanced"`, `enable_safety_checker: true`, `image_url` = la foto (subida una vez con
`fal.storage.upload`) y **`end_image_url` = la misma foto** (ver problema 1). Con `balanced` el modelo reescribe el
prompt en un guion con marcas de tiempo; el guion real de cada video queda guardado en `data/videos/<clave>.json`
(`prompt_expandido`) y se puede ver en `/api/videos`.

### Caché
Clave = `sha1(tienda | producto | versión de plantilla | resolución | duración | bucle | respuesta normalizada)`.
Normalizar = minúsculas, sin acentos ni puntuación, sin artículos/preposiciones iniciales, así «Con un Café con
Leche» y «cafe con leche» reciben el mismo video. Los aciertos se ven en `/metricas` como «servidos desde caché» y
en la tasa de caché; el widget además ofrece como chips las respuestas que ya tienen video (siempre instantáneas).

### Límites que no rompen
`LIMITE_IP_DIA=3` generaciones nuevas por IP y día (UTC) y `LIMITE_TOTAL=40` en total. Se reservan antes de
llamar a fal y se devuelven si la generación falla. Al superarlos, o si fal falla, el servidor responde 200 con el
video más visto del mismo producto y el widget lo explica; si no existe ninguno, muestra la foto. Nunca un error.
Las generaciones del operador (pruebas, precalentado, medición) se registran con su origen y costo pero no
consumen los límites de visitante.

### Métricas (`/metricas`, datos en `/api/metricas`)
Solicitudes, nuevas, caché, tasa de caché, latencia media/p50/p90 (navegador y servidor, nuevas y caché),
tiempos de fal (total e inferencia GPU), segundos generados y costo a la tarifa vigente **y** a tarifa de lista.

---

## Tarifa (leída en la página del modelo el 2026-09-05)

> Video costs $0.0125 per second at 480p, $0.02 per second at 768p. Note: these are promotional launch rates,
> 75% off for a limited time. The discount ends September 7, after which 480p is $0.05/second and 768p is $0.08/second.

Fuente: https://fal.ai/models/minimax/h3-max/image-to-video (la página general `/pricing` no lista este modelo).
Un video de 5 s a 768P cuesta **$0.10** hasta el 7 de septiembre de 2026 y **$0.40** después. `lib/tarifa.js`
aplica la tarifa por fecha de generación y el panel muestra ambos importes.

---

## Números medidos (URL pública, 2026-09-05 ≈ 18:10 UTC)

Medidos **en el navegador por el propio widget** (clic → primer fotograma) contra
https://widget.srv1820917.hstgr.cloud desde un portátil en El Salvador; el servidor es un VPS de Hostinger en
Europa y la generación ocurre en fal.ai. Cada fila es una respuesta que no existía en caché.

### Criterio 1 · visitante nuevo, respuesta nueva, menos de 15 s: **5 de 5**

| # | Tienda | Respuesta | Navegador | Servidor | fal (envío→resultado) |
|---|---|---|---|---|---|
| 1 | perros | «un beagle tricolor» | **6.4 s** | 5.8 s | 5.5 s |
| 2 | perros | «un caniche blanco» | **7.1 s** | 6.4 s | 6.1 s |
| 3 | perros | «un corgi» | **7.6 s** | 7.0 s | 6.4 s |
| 4 | galletas | «con un té chai» | **7.6 s** | 7.0 s | 6.4 s |
| 5 | galletas | «con un batido de fresa» | **7.1 s** | 6.5 s | 6.0 s |

Media 7.2 s · p90 7.6 s · máximo 7.6 s. Extra, fuera de la serie: la tienda de prueba «velas» embebida desde
otro origen (`localhost:8765`) generó en **8.4 s**. Desglose típico de una generación nueva vista desde el VPS:
cola de fal 0.27–0.30 s · inferencia GPU 2.93–3.00 s · resto del pipeline de fal (expansión del prompt, subida a
su CDN) ≈ 2.5 s · descarga del mp4 al VPS 0.25–0.6 s · entrega al navegador y primer fotograma ≈ 0.6 s.
Durante el desarrollo, la misma petición hecha desde el portátil (descarga del mp4 por red doméstica) tardó
6.9–13.2 s: el paso «descarga al servidor» es el que más depende de dónde vive el servidor.

### Criterio 3 · la misma respuesta no vuelve a generar

| Respuesta repetida | Modo | Navegador |
|---|---|---|
| «con un té chai» | caché | 0.6 s |
| «un corgi» | caché | 0.6 s |
| «Un BEAGLE tricolor!!» (otra grafía de «un beagle tricolor») | caché | 0.5 s |
| «En la mesita de noche» (tienda velas, otro origen) | caché | 0.2 s |

En el panel: 12 solicitudes de visitante → 7 nuevas, 5 caché, **tasa de caché 41.7 %**, 0 límites, 0 errores.

### Criterio 5 · panel `/metricas` en el momento de la entrega

| Métrica | Valor |
|---|---|
| Solicitudes de visitantes | 12 (7 nuevas · 5 caché · 0 por límite · 0 errores) |
| Tasa de caché | 41.7 % |
| Latencia navegador · video nuevo | media 7.5 s · p50 7.6 s · p90 8.6 s (n = 7; incluye una medición local de 8.6 s sembrada con los datos iniciales) |
| Latencia navegador · caché | media 0.45 s · p90 0.63 s (n = 5) |
| Latencia servidor · video nuevo | media 6.9 s · p90 8.0 s |
| fal · envío → resultado | media 5.8 s · p90 6.4 s (n = 20) |
| fal · inferencia GPU | media 2.91 s · p90 2.99 s (n = 20) |
| Generaciones | 20 = 7 de visitantes + 7 de la puerta de pruebas + 6 de precalentado |
| Segundos de video generados | 100 s (768P, 5 s cada uno) |
| Costo a la tarifa vigente ($0.02/s) | **$2.00** ($0.70 visitantes · $0.70 pruebas · $0.60 precalentado) |
| Mismo volumen a tarifa de lista ($0.08/s, desde el 8 sep) | $8.00 |

Gasto real total del proyecto en fal: ≈ **$2.31** = $2.00 (panel público) + $0.20 (dos generaciones locales del
widget que no se sembraron) + $0.10 (generación de la prueba desde cero con Caddy) + ≈ $0.01 (3 fotos con flux).

### Revisión del 2026-09-06 (plantilla v3 con interacción + widget en sitio)

| Medición (URL pública, navegador) | Resultado |
|---|---|
| «un beagle» (perros), respuesta nueva, widget en sitio | **7.6 s** hasta el primer fotograma; fal 6.9 s; el video se fundió sobre la foto de la ficha |
| «un chihuahua» desde caché | 1.3 s |
| «un labrador dorado» desde caché con otro video en pantalla | 0.5 s hasta el primer fotograma; entró a los 1.3 s, al acabar el bucle anterior |

El pipeline de generación no cambió (mismo endpoint, misma duración y resolución), así que las cinco mediciones
del criterio 1 siguen siendo válidas; el límite diario de 3 por IP impidió repetir la serie completa el mismo día
UTC. Interacción verificada en 6 de 6 tiras a 2 fotogramas/segundo (ver `PRUEBAS.md`, sección «Plantilla v3»).

### Criterio 2 · el producto conserva forma, color y posición

Con la plantilla v3 (vigente) el producto **se usa**: el perro lo muerde, la galleta se moja. Lo que se exige es
que conserve forma, color y material en todo momento y que vuelva a su posición al final: **6 de 6** tiras
verificadas lo cumplen. Lo que sigue es el histórico de las plantillas v1–v2, cuando el criterio era «nadie lo toca».

Juzgado a ojo sobre hojas de contacto de 5 fotogramas (`/pruebas/…jpg`, generadas con ffmpeg):

- Configuración final (plantilla v2 + `end_image_url`): **8 de 8** videos verificados conservan forma, color y
  posición (2 de la iteración v3 + los 6 de visitantes en producción: beagle, caniche, corgi, té chai, batido de
  fresa, vela en la mesita de noche). Matiz: en «un beagle tricolor» el perro termina apoyando el hocico sobre el
  hueso (lo toca, no lo mueve). En «velas» el fondo se transforma en un dormitorio y la mesa cambia de material:
  es el entorno, no el producto.
- Plantilla v1 (antes de iterar): forma y color 3/3, posición 2/3 — con un labrador la cámara se alejaba
  (**33 % de fallos de encuadre**). v2 solo texto: 0/2 en los casos difíciles. Detalle y hojas en `PRUEBAS.md`.

### Criterio 4 · una tienda distinta instalada solo con el README

Tienda «Velas del Bosque»: se creó `tiendas/velas.json` y se copió `productos/velas-lavanda.jpg` en el servidor en
marcha (sin reiniciar: `/api/config?tienda=velas&producto=lavanda` respondió al instante) y se pegó la línea de
script en una página servida desde **otro origen** (`http://localhost:8765`). Resultado: pregunta y foto cargadas,
video nuevo en 8.4 s, repetición desde caché en 0.2 s. No se tocó `widget.js`.

### Criterio 6 · arranque desde cero con un solo comando

Probado en el VPS en un directorio limpio (`/tmp/widget-cero`, solo el código + `.env` con la clave):
`docker compose up -d --build` → imagen construida, `app` + `caddy` arriba (Caddy publicado en 8080/8443 porque
Traefik ya ocupa 80/443 allí) → `/api/salud` OK → generación real a través de Caddy en 11.4 s de servidor →
la misma respuesta desde caché en 1 ms → `/api/metricas` con cifras → `docker compose down -v` y borrado.
El despliegue público real usa `docker-compose.traefik.yml` en `/docker/widget-video` (mismo contenedor).

---

## Instalar el widget en otra tienda (sin tocar el código del widget)

1. **Registrar la tienda**: crea `tiendas/<id>.json` (id en minúsculas, dígitos y guiones). El servidor lo lee al
   instante, sin reiniciar.
   ```json
   {
     "nombre": "Velas del Bosque",
     "pregunta": "¿En qué rincón de tu casa encenderías esta vela?",
     "placeholder": "Ej.: en la mesita de noche, junto a la bañera, en la terraza…",
     "boton": "Ver mi rincón",
     "escena": "Around the candle, this place comes to life: \"{respuesta}\". The candle flame flickers softly; the room is cozy and calm.",
     "productos": {
       "lavanda": { "nombre": "Vela de lavanda 200 g", "descripcion": "lit lavender-colored soy candle in a glass jar", "imagen": "velas-lavanda.jpg" }
     }
   }
   ```
   `escena` debe contener `{respuesta}` entre comillas. `descripcion` va en inglés. Opcional: `"precalentar": [...]`.
2. **Poner la foto** en `productos/` con el nombre de `imagen` (JPG/PNG/WebP; recomendable 16:9, producto centrado,
   fondo limpio: el video hereda el encuadre de la foto).
3. **Pegar una línea** en la ficha de producto:
   ```html
   <script src="https://TU-SERVIDOR/widget.js" data-tienda="velas" data-producto="lavanda" data-imagen="#foto-producto"></script>
   ```
   `data-imagen` apunta a la `<img>` del producto que ya está en la ficha: esa foto será el video (si se omite, se
   usa la imagen más cercana al `<script>`). La pregunta y el botón aparecen justo después del `<script>`; con
   `data-contenedor="#mi-div"` se colocan donde quieras. Funciona desde cualquier dominio (CORS abierto) y aísla sus
   estilos con Shadow DOM.

Comprobar: `https://TU-SERVIDOR/api/config?tienda=velas&producto=lavanda` devuelve la pregunta y la foto.

---

## Problemas reales que encontré y cómo los resolví

1. **Con perros grandes, el modelo alejaba la cámara y el producto se encogía.** En la puerta de pruebas, el
   hueso pasaba de 1/3 del ancho del cuadro a 1/8 mientras entraba un labrador (forma y color intactos, encuadre
   no). Endurecer la plantilla («no reframing… the camera never pulls back to fit it») no cambió nada: el guion
   expandido ya decía «static shot» y el modelo reencuadraba igual. Lo que funcionó fue el propio endpoint:
   `end_image_url` («first-to-last keyframe generation») con **la misma foto** como último fotograma. Si el video
   tiene que terminar exactamente en la foto, no puede acabar alejado ni desplazado, y de regalo el bucle es limpio.
   Con labrador y pastor alemán el hueso mantuvo tamaño y posición en los 5 fotogramas. Detalle en `PRUEBAS.md`.
2. **`prompt_expansion_mode: balanced` reescribe el prompt entero.** El modelo no genera a partir de mi texto,
   sino de un guion que él redacta (con lente, sonido y música). Al principio parecía perder control; en la
   práctica conserva las órdenes clave y añade tiempos («At 00:01.000, a Golden Retriever walks in…»). Decidí
   dejarlo activo, **guardar el guion expandido junto a cada video** (`data/videos/<clave>.json`) para poder
   reproducir o depurar, y apoyarme en `end_image_url` para lo que el texto no garantiza. El OpenAPI no cierra el
   enum de ese campo (solo ejemplos `balanced|quality`), así que no asumí ningún valor «off».
3. **Servidor y scripts pisándose los archivos JSON.** Al ejecutar el precalentado mientras el servidor corría,
   cada proceso guardaba su copia en memoria de `indice.json`/`metricas.json` y el último en escribir borraba lo del
   otro. Solución sin base de datos: cada módulo **fusiona con el disco antes de escribir** y relee si cambió la
   fecha de modificación; los eventos se deduplican por `ts|id` y los contadores de visitas toman el máximo.
4. **Un chip «ya tiene video» podía disparar una generación nueva.** Al cambiar la plantilla (que forma parte
   de la clave de caché), los videos antiguos seguían listados como «instantáneos» pero su clave ya no coincidía.
   Ahora la lista de chips filtra por versión de plantilla, resolución, duración y bucle actuales: un chip es
   siempre un acierto de caché.
5. **Escribir archivos con comillas simples desde el shell del agente fallaba en silencio.** Anécdota de
   herramienta, no del producto: los heredocs con `'` se cortaban; pasé a escribir los archivos con el editor.
6. **No había fotos ni carpeta `productos/` en la máquina.** El encargo las daba por existentes. Generé dos
   fotos de producto tipo e-commerce con `fal-ai/flux/schnell` (mismo proveedor y clave, seed fija, prompt guardado
   en un `.json` al lado) y las traté como «las fotos reales» de las tiendas de demostración.
7. **La clave `FAL_KEY` no estaba en el entorno.** Joshi la usa por sesión de PowerShell. Se guarda en un único
   `.env` (ignorado por git y Syncthing) y en el VPS en `/docker/widget-video/.env` (root, 600); nunca en código.
8. **Los videos «decoraban» el producto en vez de usarlo.** Con la plantilla de «nadie lo toca», el perro se
   sentaba al lado del hueso y la taza aparecía junto a las galletas: correcto para el criterio de posición,
   pero sin vida. La v3 pide interacción real (morder, mojar, sostener) y confía la vuelta al sitio a
   `end_image_url`, no al texto. Resultado: el perro muerde y mastica (el pastor alemán lo levanta y lo devuelve),
   la galleta se moja en el café, la leche o el helado y vuelve a la pila; forma y color intactos en 6 de 6 tiras.
9. **Un video en un recuadro aparte con «Generando tu video…» rompía la ficha.** Ahora el widget convierte la
   propia `<img>` del producto: la envuelve, superpone un `<video>` del mismo tamaño y radio, hace respirar la foto
   mientras espera y funde el video sobre ella al primer fotograma. Dos detalles que costaron: (a) al quitar la
   animación de respiración la foto saltaba a escala 1 — se copia el `transform` computado al video y a la foto y
   se deja que una transición los devuelva a 1; (b) al pedir una segunda respuesta, el nuevo video cortaba al
   anterior a mitad de escena — ahora espera a que el anterior llegue al final de su bucle (termina en la foto) y
   entra ahí. El tiempo medido sigue siendo clic → primer fotograma; la espera es solo visual.

---

## Estructura

```
server.js                 servidor http nativo: estáticos, API, videos con Range, métricas
lib/env.js                .env + configuración        lib/sanear.js     limpieza y normalización del texto
lib/plantillas.js         prompts (preámbulo/cierre)  lib/fal.js        cliente oficial fal (video, subida, fotos)
lib/generador.js          caché → en vuelo → límites → fal → caché → métricas (nunca rompe)
lib/cache.js  lib/limites.js  lib/metricas.js  lib/imagenes.js  lib/tarifa.js  lib/tiendas.js
public/widget.js          el script embebible (Shadow DOM, cronómetro en el navegador)
public/*.html             landing, dos demos, instalar, métricas
tiendas/*.json            configuración por tienda (pregunta, escena, productos, precalentar)
productos/                fotos de producto (+ .json con cómo se generaron las de las demos)
scripts/prueba-gate.js    puerta de pruebas (videos + hojas de contacto)   scripts/precalentar.js
scripts/medir.js          latencia lado servidor contra un servidor en marcha   scripts/desplegar-vps.sh
data/                     runtime: videos/, indice.json, limites.json, metricas.json, subidas.json, pruebas/
Dockerfile  docker-compose.yml (app + Caddy)  Caddyfile  docker-compose.traefik.yml
```

## Variables (`.env`)

| Variable | Por defecto | Qué hace |
|---|---|---|
| `FAL_KEY` | — | clave de fal.ai (obligatoria para generar) |
| `SITIO` | `:80` | dirección que sirve Caddy (`widget.tu-dominio.com` → HTTPS automático) |
| `PUERTO_HTTP` / `PUERTO_HTTPS` | `80` / `443` | puertos publicados por Caddy |
| `LIMITE_IP_DIA` / `LIMITE_TOTAL` | `3` / `40` | generaciones nuevas por IP-día y totales |
| `RESOLUCION` / `DURACION` | `768P` / `5` | parámetros del endpoint (`480P|768P`, `5..15`) |
| `BUCLE` | `1` | `end_image_url` = la misma foto (vuelve al encuadre inicial) |
| `EXPANSION_PROMPT` | `balanced` | `prompt_expansion_mode` |
| `URL_PUBLICA` | — | si se define, fal lee la foto desde aquí en vez de subirla |
| `CONFIAR_PROXY` | `1` | tomar la IP de `X-Forwarded-For` (Caddy/Traefik) |
