Saltar al contenido
motionframe

Buscar piezas

Categorías

La pieza

Qué hace y dónde encaja

Un fondo de vídeo a pantalla completa: la cámara planea despacio sobre un mar de dunas iluminado por la luna, con crestas vivas, ondulaciones de viento en la arena, estrellas que titilan y una Vía Láctea tenue. El loop de 8 s está renderizado en Blender y cierra sin costura porque el terreno es periódico. Encima, un shader WebGL mapea la luminancia del vídeo a tres colores, así que la misma grabación sirve en violeta, ámbar o glaciar (o en los colores de tu marca) sin volver a renderizar; añade grano, viñeta, un paralaje mínimo con el ratón y alguna estrella fugaz. Trae titular centrado, botón de pausa y progreso del bucle. Encaja en portadas de hoteles del desierto, experiencias, festivales, música, marcas de cosmética nocturna o cualquier hero que necesite calma y profundidad.

Prompt
≈9.671 tokens · con Lite
Librerías
ninguna
Demo
21,1 KB · ligero
Prueba en frío
pendiente

Antes de copiar

Tu marca, dentro del prompt

Cambia lo que quieras y copia: el prompt sale ya con el nombre, los colores y los textos de tu cliente. Lo que dejes en blanco se queda como en la demo.

Colores

Sin cambios: se copia con los valores de la demo.

Gratis y entero

El prompt, entero y a la vista

Qué trae el prompt, por apartados

  1. 01Qué es
  2. 02Librerías y esqueleto
  3. 03Tokens de diseño
  4. 04Estructura y contenido
  5. 05Movimiento
  6. 06Código clave
  7. 07Interacción
  8. 08Adaptación a móvil y tablet
  9. 09Accesibilidad y movimiento reducido
  10. 10Rendimiento
  11. 11Personalización
  12. 12Comprobación final
Completo
3.900 palabras · ≈9.671 tokens
Lite
1.053 palabras · ≈2.256 tokens
Librerías
ninguna
Peso de la demo
21,1 KB (ligero)
Resolución máxima (DPR)
2
Se pausa fuera de pantalla
sí
Respeta «menos movimiento»
sí
Alternativa sin WebGL
sí

Tokens estimados para español (≈3,6 caracteres por token): sirven para orientarte, no son exactos.

prompt.md
# Recrea en un único archivo HTML: Dunas nocturnas

Un fondo a pantalla completa en el que la cámara planea despacio sobre un mar de dunas violetas bajo la luna y las estrellas: calma, profundidad y un punto de misterio.

Eres un desarrollador front-end creativo senior. Genera un único `index.html` autocontenido que
reproduzca exactamente la pieza descrita: mismo layout, textos, colores, movimiento e interacción.
HTML, CSS y JS en un solo archivo, sin build ni frameworks. Librerías solo por CDN y en las versiones
indicadas. Todos los valores numéricos de este documento son definitivos: úsalos tal cual.
Responde solo con el archivo completo.

## Qué es

- **El fondo es un vídeo en bucle ya renderizado** (Blender, 8 s a 30 fps, 1920×1080 y 1280×720). No lo generes tú:
  cárgalo desde las URL de la sección «Recursos». Muestra un vuelo rasante hacia delante sobre dunas con crestas vivas y
  ondulaciones de viento, luna lateral desde la derecha (fuera de cuadro), cielo añil con estrellas que titilan, una Vía
  Láctea tenue arriba a la izquierda y bruma en el horizonte. **El bucle es perfecto**: el fotograma 240 sería idéntico al 0,
  así que `loop` no deja costura.
- **Encima, un lienzo WebGL** pinta ese vídeo como textura con un shader de pantalla completa que:
  1. lo encuadra como `object-fit: cover` con un 3,5 % de margen,
  2. lo desplaza un poco con el ratón (paralaje),
  3. **mapea su luminancia a tres colores** (sombra, medios, luces) y lo mezcla con el original según `uMezcla`. Así la
     misma grabación sirve en tres paletas conmutables (Violeta, Ámbar, Glaciar) o en los colores de cualquier marca,
  4. añade de vez en cuando una estrella fugaz, una viñeta y grano de película animado (que además evita las bandas del
     degradado del cielo).
- **Capa de interfaz mínima**: antetítulo y titular centrados en el cielo; abajo, selector de paleta, coordenadas
  decorativas, barra de progreso del bucle y botón de pausa.
- **Degradación**: sin WebGL (o si el vídeo es de otro dominio sin CORS) se muestra el `<video>` tal cual con un filtro CSS
  que aproxima la paleta; si el vídeo no carga, queda el póster; si tampoco, un degradado CSS de los mismos colores.

## Librerías y esqueleto

Sin librerías: WebGL 1 a mano y JS moderno en un `<script>` clásico dentro de una IIFE.

Fuentes de Google (las dos tienen ñ, tildes, ¿ y ¡):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@500;600;700&display=swap" rel="stylesheet">
```

`<html lang="es">`, `<meta charset="utf-8">`, `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">`,
`<title>Dunas nocturnas</title>`.

### Recursos (vídeo y póster)

Todos en la misma carpeta; la constante `ASSETS` apunta a ella:

```js
const ASSETS = 'https://motionframe-two.vercel.app/assets/dunas-nocturnas';
```

| Archivo | Uso | Peso aprox. |
|---|---|---|
| `https://motionframe-two.vercel.app/assets/dunas-nocturnas/dunas-1080.webm` | VP9 1920×1080, primera opción | 0,7 MB |
| `https://motionframe-two.vercel.app/assets/dunas-nocturnas/dunas-1080.mp4` | H.264 1920×1080, respaldo | 1,8 MB |
| `https://motionframe-two.vercel.app/assets/dunas-nocturnas/dunas-720.webm` | VP9 1280×720 (lienzos de ≤ 760 px de alto reales) | 0,35 MB |
| `https://motionframe-two.vercel.app/assets/dunas-nocturnas/dunas-720.mp4` | H.264 1280×720, respaldo | 0,75 MB |
| `https://motionframe-two.vercel.app/assets/dunas-nocturnas/dunas-poster-1080.webp` | primer fotograma, 1920 px | 23 KB |
| `https://motionframe-two.vercel.app/assets/dunas-nocturnas/dunas-poster-720.webp` | primer fotograma, 1280 px | 13 KB |

El servidor de esas URL envía `Access-Control-Allow-Origin: *`, así que el vídeo se puede leer desde WebGL en cualquier
dominio si el `<video>` lleva `crossorigin="anonymous"`. Si prefieres alojar los archivos en tu propio proyecto, cópialos
a una carpeta pública y cambia solo `ASSETS` (mismo origen: no hace falta CORS).

## Tokens de diseño

```css
:root{
  --sombra:#0e0a1c; --medio:#4d3f78; --luz:#d8c9ef;
  --tinta:#f4efff; --tinta-2:rgba(244,239,255,.72); --linea:rgba(244,239,255,.16);
  --display:"Inter Tight", system-ui, sans-serif;
  --sans:"Inter Tight", system-ui, sans-serif;
  --curva:cubic-bezier(.16,.84,.24,1);
  --gutter:clamp(18px, 4vw, 56px);
}
```

Las tres paletas (la primera es la de por defecto y la de las variables):

| Paleta | sombra | medio | luz | mezcla | filtro CSS de respaldo |
|---|---|---|---|---|---|
| Violeta | `#0e0a1c` | `#4d3f78` | `#d8c9ef` | 0.35 | `none` |
| Ámbar | `#140906` | `#7a3f22` | `#f6d2a0` | 1.0 | `sepia(.6) saturate(1.5) hue-rotate(-20deg)` |
| Glaciar | `#050c16` | `#2a5270` | `#d2e8f3` | 1.0 | `hue-rotate(-52deg) saturate(.72) brightness(1.06)` |

`mezcla` es cuánto del mapa de color sustituye al vídeo original: con 0.35 la Violeta conserva los matices del render
(que ya es violeta) y solo los unifica; Ámbar y Glaciar lo sustituyen del todo.

## Estructura y contenido

```
main.escena#escena[data-modo="webgl"]           ← 100svh, a sangre, overflow hidden
  img.escena__poster#poster (aria-hidden, alt="")
  video.escena__video#video (muted loop playsinline preload="auto" disablepictureinpicture, aria-hidden)
  canvas.escena__lienzo#lienzo (aria-hidden)
  header.titular
    p.titular__ante.entra                         «Namib · 03:12 h»
    h1.entra.entra--2                             «Donde el viento dibuja la noche» con la palabra «dibuja» dentro de <em> (peso 700, sin cursiva)
  div.barra.entra.entra--3
    div.paletas[role=group][aria-label="Paleta de color"]
      button.paleta[data-paleta=violeta][aria-pressed=true]  muestra + «Violeta»
      button.paleta[data-paleta=ambar][aria-pressed=false]   muestra + «Ámbar»
      button.paleta[data-paleta=glaciar][aria-pressed=false] muestra + «Glaciar»
    p.coords[aria-hidden]                          «24°44′ S · 15°17′ E»
    div.control
      span.progreso[aria-hidden] > i#progreso
      button.pausa#pausa[aria-pressed=false][aria-label="Pausar el fondo"]  dos iconos SVG (pausa / play)
  p.oculto#aviso[aria-live=polite]                 anuncia «Paleta Violeta», «Paleta Ámbar»…
```

HTML literal del cuerpo:

```html
<main class="escena" id="escena" data-modo="webgl">
  <img class="escena__poster" id="poster" alt="" aria-hidden="true" decoding="async">
  <video class="escena__video" id="video" muted loop playsinline preload="auto" aria-hidden="true" disablepictureinpicture></video>
  <canvas class="escena__lienzo" id="lienzo" aria-hidden="true"></canvas>

  <header class="titular">
    <p class="titular__ante entra">Namib · 03:12 h</p>
    <h1 class="entra entra--2">Donde el viento dibuja la noche</h1>   <!-- envuelve la palabra «dibuja» en <em>…</em> -->
  </header>

  <div class="barra entra entra--3">
    <div class="paletas" role="group" aria-label="Paleta de color">
      <button class="paleta" type="button" data-paleta="violeta" aria-pressed="true" style="--c1:#0e0a1c;--c2:#4d3f78;--c3:#d8c9ef">
        <span class="paleta__muestra" aria-hidden="true"></span><span class="paleta__nombre">Violeta</span></button>
      <button class="paleta" type="button" data-paleta="ambar" aria-pressed="false" style="--c1:#140906;--c2:#7a3f22;--c3:#f6d2a0">
        <span class="paleta__muestra" aria-hidden="true"></span><span class="paleta__nombre">Ámbar</span></button>
      <button class="paleta" type="button" data-paleta="glaciar" aria-pressed="false" style="--c1:#050c16;--c2:#2a5270;--c3:#d2e8f3">
        <span class="paleta__muestra" aria-hidden="true"></span><span class="paleta__nombre">Glaciar</span></button>
    </div>
    <p class="coords" aria-hidden="true">24°44′ S · 15°17′ E</p>
    <div class="control">
      <span class="progreso" aria-hidden="true"><i id="progreso"></i></span>
      <button class="pausa" id="pausa" type="button" aria-pressed="false" aria-label="Pausar el fondo">
        <svg class="i-pausa" viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 1.5h2.2v9H2.5zM7.3 1.5h2.2v9H7.3z" fill="currentColor"/></svg>
        <svg class="i-play" viewBox="0 0 12 12" aria-hidden="true"><path d="M3 1.4l7.2 4.6L3 10.6z" fill="currentColor"/></svg>
      </button>
    </div>
  </div>
  <p class="oculto" aria-live="polite" id="aviso"></p>
</main>
```

CSS literal (va después del bloque `:root`):

```css
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;background:var(--sombra);color:var(--tinta);overflow:hidden}
body{font-family:var(--sans);-webkit-font-smoothing:antialiased}
.escena{position:relative;width:100%;height:100svh;min-height:100%;overflow:hidden;isolation:isolate;
  background:
    radial-gradient(120% 70% at 72% 58%, color-mix(in srgb, var(--medio) 55%, transparent), transparent 70%),
    linear-gradient(180deg, #06040d 0%, var(--sombra) 45%, color-mix(in srgb, var(--medio) 60%, var(--sombra)) 62%, var(--sombra) 100%);
  transition:background-color 1s var(--curva)}
.escena__poster,.escena__video,.escena__lienzo{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.escena__poster{opacity:.0;transition:opacity 1.2s var(--curva)}
.escena__poster.listo{opacity:1}
.escena__video{opacity:0;transition:opacity 1.2s var(--curva),filter .9s var(--curva)}
.escena__lienzo{opacity:0;transition:opacity 1.4s var(--curva)}
.escena[data-modo="webgl"] .escena__lienzo.listo{opacity:1}
.escena[data-modo="video"] .escena__video.listo{opacity:1}
.escena::after{content:"";position:absolute;inset:0;pointer-events:none;z-index:2;
  background:linear-gradient(180deg, rgba(6,4,13,.55) 0%, transparent 34%, transparent 70%, rgba(6,4,13,.6) 100%)}

.titular{position:absolute;z-index:3;left:0;right:0;top:clamp(84px, 15svh, 150px);padding:0 var(--gutter);text-align:center}
.titular__ante{display:inline-flex;align-items:center;gap:.7em;font-size:12px;letter-spacing:.24em;text-transform:uppercase;
  color:var(--tinta-2);font-weight:500}
.titular__ante::before,.titular__ante::after{content:"";width:28px;height:1px;background:currentColor;opacity:.55}
.titular h1{margin-top:18px;font-family:var(--display);font-weight:600;font-size:clamp(46px, 7.4vw, 118px);line-height:.94;
  letter-spacing:-.04em;text-wrap:balance;text-shadow:0 2px 40px rgba(6,4,13,.45)}
.titular h1 em{font-style:normal;font-weight:700;color:var(--luz);transition:color .9s var(--curva)}
.entra{opacity:0;transform:translateY(18px);filter:blur(8px);animation:entra 1.4s var(--curva) forwards}
.entra--2{animation-delay:.18s}.entra--3{animation-delay:.55s}
@keyframes entra{to{opacity:1;transform:none;filter:none}}

.barra{position:absolute;z-index:3;left:0;right:0;bottom:0;padding:0 var(--gutter) max(22px, env(safe-area-inset-bottom));
  display:grid;grid-template-columns:1fr auto 1fr;align-items:end;gap:16px}
.paletas{display:flex;gap:6px;align-items:center}
.paleta{appearance:none;border:1px solid var(--linea);background:rgba(10,7,20,.35);color:var(--tinta-2);
  font:500 12px/1 var(--sans);letter-spacing:.04em;border-radius:999px;padding:6px 12px 6px 6px;display:inline-flex;gap:8px;
  align-items:center;cursor:pointer;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
  transition:border-color .4s var(--curva),color .4s var(--curva),background-color .4s var(--curva)}
.paleta:hover{color:var(--tinta);border-color:rgba(244,239,255,.34)}
.paleta[aria-pressed="true"]{color:var(--tinta);border-color:rgba(244,239,255,.6);background:rgba(10,7,20,.55)}
.paleta__muestra{width:18px;height:18px;border-radius:50%;flex:none;
  background:linear-gradient(135deg, var(--c3) 0%, var(--c2) 52%, var(--c1) 100%);box-shadow:inset 0 0 0 1px rgba(255,255,255,.18)}
.coords{font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:var(--tinta-2);text-align:center;white-space:nowrap;
  font-variant-numeric:tabular-nums}
.control{justify-self:end;display:flex;align-items:center;gap:12px}
.progreso{width:92px;height:1px;background:var(--linea);position:relative;overflow:hidden}
.progreso i{position:absolute;inset:0;background:var(--luz);transform-origin:left;transform:scaleX(0);transition:background-color .9s var(--curva)}
.pausa{appearance:none;width:38px;height:38px;border-radius:50%;border:1px solid var(--linea);background:rgba(10,7,20,.35);
  color:var(--tinta);display:grid;place-items:center;cursor:pointer;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
  transition:border-color .4s var(--curva)}
.pausa:hover{border-color:rgba(244,239,255,.4)}
.pausa svg{width:12px;height:12px}
.pausa .i-play{display:none}.pausa[aria-pressed="true"] .i-play{display:block}.pausa[aria-pressed="true"] .i-pausa{display:none}
:focus-visible{outline:2px solid var(--luz);outline-offset:3px}
.oculto{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}

@media (max-width:760px){
  .barra{grid-template-columns:1fr auto;row-gap:14px}
  .coords{grid-column:1 / -1;grid-row:1;text-align:left}
  .paleta{padding:6px}
  .paleta__nombre{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
  .paleta__muestra{width:22px;height:22px}
  .progreso{width:56px}
  .titular{top:clamp(72px, 13svh, 120px)}
}
@media (prefers-reduced-motion:reduce){
  .entra{animation:none;opacity:1;transform:none;filter:none}
  .escena__lienzo,.escena__video,.escena__poster{transition:none}
}
```

## Movimiento

| Elemento | Propiedad | De → a | Duración | Easing | Retardo | Disparador |
|---|---|---|---|---|---|---|
| Vídeo de fondo | reproducción | bucle de 8 s | continuo | — | — | carga (`loop`, `muted`) |
| Antetítulo | opacity · translateY · blur | 0 · 18px · 8px → 1 · 0 · 0 | 1,4 s | `cubic-bezier(.16,.84,.24,1)` | 0 s | carga |
| Titular | ídem | ídem | 1,4 s | ídem | 0,18 s | carga |
| Barra inferior | ídem | ídem | 1,4 s | ídem | 0,55 s | carga |
| Póster | opacity | 0 → 1 | 1,2 s | `--curva` | — | `load` de la imagen |
| Lienzo WebGL | opacity | 0 → 1 | 1,4 s | `--curva` | — | primer fotograma subido a la textura |
| Paleta (shader) | sombra, medio, luz, mezcla | actual → nueva | 0,9 s | `1 − (1 − x)³` | — | clic o teclas 1/2/3 |
| Paleta (CSS) | `--sombra/--medio/--luz`, color del `<em>` y del progreso | actual → nueva | 0,9 s | `--curva` | — | ídem |
| Paralaje | desplazamiento UV | ±0,011 · ±0,007 | continuo | lerp 0,06 por fotograma a 60 fps | — | ratón |
| Estrella fugaz | trazo en el cielo | aparece y cruza | 0,95 s cada 7 s (salta el 30 % de los ciclos) | salida cuadrática | primera a ~2,4 s | tiempo |
| Grano | ruido | cambia cada fotograma | — | — | — | tiempo |
| Progreso | scaleX | `currentTime / duration` | continuo | — | — | cada fotograma |

## Código clave

### Shaders (literales)

```js
const VS = 'attribute vec2 p;varying vec2 vUv;void main(){vUv=p*.5+.5;gl_Position=vec4(p,0.,1.);}';
const FS = `precision highp float;
varying vec2 vUv;
uniform sampler2D uVid;
uniform vec2 uRes, uVidRes, uPtr;
uniform float uTime, uMezcla, uMeteoro;
uniform vec3 uSombra, uMedio, uLuz;
float hash(vec2 p){p=fract(p*vec2(123.34,456.21));p+=dot(p,p+45.32);return fract(p.x*p.y);}
vec2 cubrir(vec2 uv){
  float ra=uRes.x/uRes.y, rv=uVidRes.x/uVidRes.y;
  vec2 s=ra>rv?vec2(1.,rv/ra):vec2(ra/rv,1.);
  return (uv-.5)*s+.5;
}
float meteoro(vec2 uv,float t){
  float per=7.;
  float k=floor(t/per), lt=t-k*per, dur=.95;
  if(lt>dur||hash(vec2(k,3.3))<.3) return 0.;
  float rv=uVidRes.x/uVidRes.y;
  vec2 p=uv*vec2(rv,1.);
  float h1=hash(vec2(k,1.7)), h2=hash(vec2(k,9.1));
  vec2 a=vec2((.3+.62*h1)*rv,.95-.07*h2);
  vec2 d=normalize(vec2(-1.,-.36-.18*h2));
  float pr=lt/dur, e=1.-pow(1.-pr,2.);
  vec2 cab=a+d*.36*e;
  float largo=.05+.13*pr;
  vec2 q=p-cab;
  float s=clamp(dot(q,-d)/largo,0.,1.);
  float dist=length(q+d*s*largo);
  float nucleo=(1.-smoothstep(0.,.0016+.0022*(1.-s),dist))*pow(1.-s,2.2);
  float halo=exp(-dist*260.)*pow(1.-s,3.)*.35;
  return (nucleo+halo)*sin(3.14159*pr)*smoothstep(.6,.7,uv.y);
}
void main(){
  vec2 uv=cubrir(vUv);
  uv=(uv-.5)/1.035+.5+uPtr*vec2(.011,.007);
  vec3 c=texture2D(uVid,uv).rgb;
  float l=dot(c,vec3(.2126,.7152,.0722));
  float t=clamp(pow(l/.6,.86),0.,1.);
  vec3 g=t<.5?mix(uSombra,uMedio,smoothstep(0.,.5,t)):mix(uMedio,uLuz,smoothstep(.5,1.,t));
  g+=max(l-.6,0.)*1.3;
  vec3 col=mix(c,g,uMezcla);
  col+=mix(uLuz,vec3(1.),.45)*meteoro(uv,uTime)*uMeteoro;
  vec2 v=(vUv-.5)*vec2(1.15,1.);
  col*=mix(.7,1.,smoothstep(.95,.25,length(v)));
  col+=(hash(vUv*uRes+fract(uTime*7.31)*91.7)-.5)*.034;
  gl_FragColor=vec4(col,1.);
}`;
```

Claves del shader:
- `cubrir()` replica `object-fit: cover` para cualquier proporción de pantalla; el `/1.035` deja margen para el paralaje sin
  enseñar bordes.
- El mapa de color usa la luminancia Rec. 709 normalizada a 0,6 (el vídeo es nocturno: casi nada pasa de ahí) con gamma
  0,86; por encima de 0,6 (estrellas, brillos) se suma luz extra para que sigan siendo puntos blancos.
- La estrella fugaz solo existe con `uv.y > 0.6–0.7` (el cielo del vídeo; el horizonte está en torno a 0,62 desde abajo) y
  se calcula en coordenadas del vídeo, así que siempre cae en el cielo aunque el encuadre cambie en móvil.
- El grano (±0,017) cambia con `uTime`; con el fondo en pausa `uTime = 0` y queda quieto.

### JavaScript (literal)

```js
(() => {
  const ASSETS = 'https://motionframe-two.vercel.app/assets/dunas-nocturnas';
  const PALETAS = {
    violeta: { nombre: 'Violeta', sombra: '#0e0a1c', medio: '#4d3f78', luz: '#d8c9ef', mezcla: 0.35, filtro: 'none' },
    ambar:   { nombre: 'Ámbar',   sombra: '#140906', medio: '#7a3f22', luz: '#f6d2a0', mezcla: 1.0,  filtro: 'sepia(.6) saturate(1.5) hue-rotate(-20deg)' },
    glaciar: { nombre: 'Glaciar', sombra: '#050c16', medio: '#2a5270', luz: '#d2e8f3', mezcla: 1.0,  filtro: 'hue-rotate(-52deg) saturate(.72) brightness(1.06)' },
  };

  const escena = document.getElementById('escena');
  const video = document.getElementById('video');
  const lienzo = document.getElementById('lienzo');
  const poster = document.getElementById('poster');
  const pausaBtn = document.getElementById('pausa');
  const barraProg = document.getElementById('progreso');
  const aviso = document.getElementById('aviso');
  const reducido = matchMedia('(prefers-reduced-motion: reduce)');
  const movil = matchMedia('(max-width: 900px)').matches;

  // vídeo: 720p si el lienzo mide ≤ 760 px de alto reales, 1080p en el resto; WebM primero y MP4 de respaldo
  const tam = innerHeight * Math.min(devicePixelRatio || 1, movil ? 1.5 : 2) <= 760 ? '720' : '1080';
  poster.src = `${ASSETS}/dunas-poster-${tam}.webp`;
  poster.onload = () => poster.classList.add('listo');
  const mismoOrigen = new URL(ASSETS, location.href).origin === (self.origin || location.origin); // en un iframe con sandbox el origen es «null»
  let conCors = !mismoOrigen;                    // recurso de otro dominio: se pide con CORS para poder leerlo en WebGL
  function cargarVideo() {
    video.innerHTML = '';
    if (conCors) video.crossOrigin = 'anonymous'; else video.removeAttribute('crossorigin');
    for (const [ext, tipo] of [['webm', 'video/webm; codecs=vp9'], ['mp4', 'video/mp4']]) {
      const s = document.createElement('source');
      s.src = `${ASSETS}/dunas-${tam}.${ext}`; s.type = tipo;
      video.appendChild(s);
    }
    video.load();
  }
  let fallos = 0;
  video.addEventListener('loadeddata', () => { if (!gl) video.classList.add('listo'); });
  // si fallan las dos fuentes con CORS (el servidor no envía cabeceras), se reintenta sin CORS y sin WebGL
  function alFallarFuente() {
    if (++fallos < 2 || !conCors) return;
    conCors = false; usarModoVideo(); cargarVideo();
  }

  // WebGL
  let gl = null, prog, tex, U = {};
  // VS y FS: pega aquí, tal cual, las dos constantes del bloque «Shaders (literales)»
  function iniciarGL() {
    try {
      gl = lienzo.getContext('webgl', { antialias: false, alpha: false, premultipliedAlpha: false, powerPreference: 'high-performance' });
    } catch (e) { gl = null; }
    if (!gl) return false;
    const sh = (t, s) => { const o = gl.createShader(t); gl.shaderSource(o, s); gl.compileShader(o);
      if (!gl.getShaderParameter(o, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(o)); return o; };
    try {
      prog = gl.createProgram();
      gl.attachShader(prog, sh(gl.VERTEX_SHADER, VS)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FS));
      gl.linkProgram(prog);
      if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
    } catch (e) { gl = null; return false; }
    gl.useProgram(prog);
    const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
    const loc = gl.getAttribLocation(prog, 'p'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
    for (const n of ['uVid', 'uRes', 'uVidRes', 'uPtr', 'uTime', 'uMezcla', 'uMeteoro', 'uSombra', 'uMedio', 'uLuz']) U[n] = gl.getUniformLocation(prog, n);
    tex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, tex);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([14, 10, 28, 255]));
    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
    gl.uniform1i(U.uVid, 0);
    lienzo.addEventListener('webglcontextlost', (e) => { e.preventDefault(); usarModoVideo(); });
    return true;
  }
  function usarModoVideo() {
    gl = null; escena.dataset.modo = 'video';
    if (video.readyState >= 2) video.classList.add('listo');
    aplicarFiltro();
  }

  // paleta (transición de 900 ms)
  const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
  let actual = 'violeta';
  const col = { sombra: hex(PALETAS.violeta.sombra), medio: hex(PALETAS.violeta.medio), luz: hex(PALETAS.violeta.luz), mezcla: PALETAS.violeta.mezcla };
  let desde = null, hacia = null, tPal = 1;
  function aplicarFiltro() { video.style.filter = PALETAS[actual].filtro; }
  function elegirPaleta(nombre, anunciar = true) {
    if (!PALETAS[nombre]) return;
    actual = nombre;
    const p = PALETAS[nombre];
    desde = { sombra: col.sombra.slice(), medio: col.medio.slice(), luz: col.luz.slice(), mezcla: col.mezcla };
    hacia = { sombra: hex(p.sombra), medio: hex(p.medio), luz: hex(p.luz), mezcla: p.mezcla };
    tPal = reducido.matches ? 1 : 0;
    if (tPal === 1) Object.assign(col, { sombra: hacia.sombra, medio: hacia.medio, luz: hacia.luz, mezcla: hacia.mezcla });
    const r = document.documentElement.style;
    r.setProperty('--sombra', p.sombra); r.setProperty('--medio', p.medio); r.setProperty('--luz', p.luz);
    document.querySelectorAll('.paleta').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.paleta === nombre)));
    aplicarFiltro();
    if (anunciar) aviso.textContent = `Paleta ${p.nombre}`;
    pedirFotograma();
  }
  document.querySelectorAll('.paleta').forEach((b) => b.addEventListener('click', () => elegirPaleta(b.dataset.paleta)));
  addEventListener('keydown', (e) => {
    if (e.target.closest && e.target.closest('input,textarea')) return;
    const k = { '1': 'violeta', '2': 'ambar', '3': 'glaciar' }[e.key];
    if (k) elegirPaleta(k);
  });
  const suave = (x) => 1 - Math.pow(1 - x, 3);

  // puntero (paralaje sutil, solo con ratón)
  const ptr = { x: 0, y: 0, tx: 0, ty: 0 };
  addEventListener('pointermove', (e) => {
    if (e.pointerType !== 'mouse') return;
    ptr.tx = (e.clientX / innerWidth) * 2 - 1; ptr.ty = -((e.clientY / innerHeight) * 2 - 1);
  }, { passive: true });
  document.addEventListener('pointerleave', () => { ptr.tx = 0; ptr.ty = 0; });

  // tamaño (DPR máx. 2; 1,5 en móvil)
  function redimensionar() {
    if (!gl) return;
    const dpr = Math.min(devicePixelRatio || 1, movil ? 1.5 : 2);
    const w = Math.round(lienzo.clientWidth * dpr), h = Math.round(lienzo.clientHeight * dpr);
    if (lienzo.width !== w || lienzo.height !== h) { lienzo.width = w; lienzo.height = h; gl.viewport(0, 0, w, h); }
    pedirFotograma();
  }
  new ResizeObserver(redimensionar).observe(lienzo);

  // bucle
  let visible = true, pausadoUsuario = reducido.matches, raf = 0, ultimo = 0, t0 = performance.now(), hayFotograma = false;
  function puedeCorrer() { return visible && !document.hidden; }
  function pedirFotograma() { if (!raf) raf = requestAnimationFrame(fotograma); }
  function fotograma(ahora) {
    raf = 0;
    const dt = Math.min(0.05, (ahora - (ultimo || ahora)) / 1000); ultimo = ahora;
    const t = (ahora - t0) / 1000;
    if (tPal < 1 && hacia) {
      tPal = Math.min(1, tPal + dt / 0.9);
      const e = suave(tPal), L = (a, b) => a.map((v, i) => v + (b[i] - v) * e);
      col.sombra = L(desde.sombra, hacia.sombra); col.medio = L(desde.medio, hacia.medio); col.luz = L(desde.luz, hacia.luz);
      col.mezcla = desde.mezcla + (hacia.mezcla - desde.mezcla) * e;
    }
    const k = 1 - Math.pow(1 - 0.06, dt * 60);
    ptr.x += (ptr.tx - ptr.x) * k; ptr.y += (ptr.ty - ptr.y) * k;
    if (video.duration) barraProg.style.transform = `scaleX(${(video.currentTime / video.duration).toFixed(4)})`;
    if (gl) dibujar(t);
    const animando = !pausadoUsuario || tPal < 1 || Math.abs(ptr.tx - ptr.x) > 1e-3 || Math.abs(ptr.ty - ptr.y) > 1e-3;
    if (puedeCorrer() && (animando || !hayFotograma)) pedirFotograma();
  }
  function dibujar(t) {
    if (video.readyState >= 2) {
      try {
        gl.bindTexture(gl.TEXTURE_2D, tex);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video);
        if (!hayFotograma) { hayFotograma = true; lienzo.classList.add('listo'); }
      } catch (e) {                                // vídeo de otro dominio sin CORS: WebGL no puede leerlo
        usarModoVideo(); return;
      }
    }
    gl.uniform2f(U.uRes, lienzo.width, lienzo.height);
    gl.uniform2f(U.uVidRes, video.videoWidth || 1920, video.videoHeight || 1080);
    gl.uniform2f(U.uPtr, ptr.x, ptr.y);
    gl.uniform1f(U.uTime, pausadoUsuario ? 0 : t + 4.6);
    gl.uniform1f(U.uMezcla, col.mezcla);
    gl.uniform1f(U.uMeteoro, reducido.matches || pausadoUsuario ? 0 : 1);
    gl.uniform3fv(U.uSombra, col.sombra); gl.uniform3fv(U.uMedio, col.medio); gl.uniform3fv(U.uLuz, col.luz);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
  }

  // reproducción: pausa del usuario, fuera de pantalla, pestaña oculta
  function sincronizar() {
    const debe = puedeCorrer() && !pausadoUsuario;
    if (debe && video.paused) video.play().catch(() => {});
    if (!debe && !video.paused) video.pause();
    if (puedeCorrer()) pedirFotograma();
  }
  function marcarPausa() {
    pausaBtn.setAttribute('aria-pressed', String(pausadoUsuario));
    pausaBtn.setAttribute('aria-label', pausadoUsuario ? 'Reproducir el fondo' : 'Pausar el fondo');
  }
  pausaBtn.addEventListener('click', () => { pausadoUsuario = !pausadoUsuario; marcarPausa(); sincronizar(); });
  new IntersectionObserver((es) => { visible = es[0].isIntersecting; sincronizar(); }, { threshold: 0.01 }).observe(escena);
  document.addEventListener('visibilitychange', sincronizar);
  reducido.addEventListener('change', () => { pausadoUsuario = reducido.matches; marcarPausa(); sincronizar(); });
  video.addEventListener('loadeddata', () => { hayFotograma = false; pedirFotograma(); sincronizar(); });
  video.addEventListener('seeked', pedirFotograma);
  video.addEventListener('error', alFallarFuente, true);   // captura: los errores de <source> no burbujean

  if (!iniciarGL()) usarModoVideo(); else redimensionar();
  marcarPausa();
  cargarVideo();
})();
```

(En el archivo final, las constantes `VS` y `FS` del bloque de shaders van dentro de la IIFE, justo antes de `iniciarGL`.)

Puntos delicados:
- `UNPACK_FLIP_Y_WEBGL = true` antes de subir el vídeo: si no, el paisaje sale boca abajo.
- La textura se crea primero con 1×1 píxel del color de sombra, así el primer dibujo nunca lee una textura incompleta.
- El vídeo se sube a la textura en cada fotograma (`texImage2D` con el elemento `<video>`); solo cuando
  `readyState >= 2`.
- El `try/catch` alrededor de `texImage2D` es el que detecta un vídeo de otro dominio sin CORS (`SecurityError`) y pasa al
  modo vídeo con filtro CSS, sin romper la página.

## Interacción

- **Ratón**: paralaje sutil del paisaje (±1,1 % horizontal, ±0,7 % vertical del encuadre), suavizado con lerp 0,06. Al
  salir el puntero de la ventana vuelve al centro. Solo `pointerType === 'mouse'`.
- **Paletas**: clic en cualquiera de los tres botones o teclas `1`, `2`, `3`. Transición de 0,9 s en el shader y en CSS
  (color del `<em>`, del progreso, del foco y de los degradados de respaldo). `aria-pressed` marca la activa y
  `#aviso` la anuncia.
- **Pausa**: el botón para el vídeo, las estrellas fugaces y el grano; vuelve a reproducir al pulsarlo otra vez. Cambia su
  `aria-label` entre «Pausar el fondo» y «Reproducir el fondo».
- **Táctil**: sin paralaje; todo lo demás igual (los botones miden 34–38 px con el borde).

## Adaptación a móvil y tablet

- **≤ 760 px**: la barra pasa a dos columnas; las coordenadas suben a su propia fila, alineadas a la izquierda; los botones
  de paleta muestran solo la muestra (22 px) y el nombre queda como texto accesible oculto; el progreso mide 56 px; el
  titular baja a `top: clamp(72px, 13svh, 120px)`.
- **Titular**: `clamp(46px, 7.4vw, 118px)` con `text-wrap: balance`: una línea en escritorio, dos en móvil.
- **Encuadre**: el shader hace `cover`; en vertical se ve la franja central del vídeo, donde la composición está pensada
  para funcionar (horizonte al 38 % desde arriba, crestas que cruzan en diagonal).
- **Resolución del vídeo**: `innerHeight × DPR (máx. 1,5 en ≤ 900 px, 2 en el resto) ≤ 760` → 720p; si no, 1080p.
- Alto `100svh`; nunca `100vh` a secas. Sin scroll horizontal de 360 a 1920 px.

## Accesibilidad y movimiento reducido

- `prefers-reduced-motion: reduce`: el vídeo no arranca (se queda en su primer fotograma, ya pasado por el shader), sin
  estrellas fugaces ni grano animado, sin animaciones de entrada ni transiciones de opacidad; el botón muestra «play» por
  si la persona quiere verlo en movimiento. Si la preferencia cambia en caliente, se respeta.
- Todo lo decorativo (`img`, `video`, `canvas`, coordenadas, progreso) lleva `aria-hidden="true"`.
- Botones reales con `type="button"`, foco visible (`outline: 2px solid var(--luz)`), controlables con teclado.
- Contraste: texto `#f4efff` sobre cielo casi negro (> 12:1); el texto secundario al 72 % sigue por encima de AA.
- WCAG 2.2.2: el movimiento dura más de 5 s, por eso hay botón de pausa siempre visible.

## Rendimiento

- DPR máximo 2 (1,5 en pantallas de ≤ 900 px de ancho).
- El bucle `requestAnimationFrame` se detiene cuando la escena no está en pantalla (IntersectionObserver, umbral 0,01) o
  la pestaña está oculta, y también el vídeo (`pause()`); se reanuda al volver.
- Con el fondo en pausa y el ratón quieto no se dibuja nada: solo se piden fotogramas mientras hay transición de paleta o
  paralaje por asentar.
- Sin WebGL: `data-modo="video"` y el `<video>` visible con el filtro CSS de la paleta. Si el vídeo no carga, queda el
  póster WebP; si tampoco, el degradado CSS de `.escena` con los mismos colores.
- Pérdida del contexto WebGL → modo vídeo sin recargar.
- Peso: el HTML ≈ 20 KB; 1080p ≈ 0,7 MB en WebM; 720p ≈ 0,35 MB.

## Personalización

- **Colores**: cambia `#0e0a1c`, `#4d3f78` y `#d8c9ef` (en `:root`, en `PALETAS.violeta` y en el
  `style` del primer botón). Para que la paleta mande del todo sobre el vídeo, sube su `mezcla` a 1.0; para conservar más
  el render original, bájala (0 = vídeo sin tocar). Reglas: sombra casi negra (luminancia < 8 %), medio a mitad de camino,
  luz muy clara y poco saturada.
- **Paletas propias**: añade entradas a `PALETAS` y botones con el mismo `data-paleta`; el filtro CSS es solo el respaldo.
- **Texto**: `Namib · 03:12 h`, `Donde el viento dibuja la noche` (con `dibuja` en `<em>`) y `24°44′ S · 15°17′ E`. Para dejar solo el
  fondo, borra `header.titular` y, si quieres, `.coords`.
- **Usarlo como fondo de tu propio hero**: conserva `main.escena` con `img`, `video` y `canvas` y pon tu contenido encima
  con `position: relative; z-index: 3`. El `::after` de `.escena` oscurece arriba y abajo para que se lea; si tu texto va a
  la izquierda, añade `linear-gradient(90deg, rgba(6,4,13,.6), transparent 55%)` a ese `::after`.
- **Sin estrellas fugaces**: fija `uMeteoro` a 0. **Más calma**: `video.playbackRate = 0.75` en `loadeddata`.
- **Alojamiento propio**: descarga los seis archivos de «Recursos» y cambia solo `ASSETS`.

## Comprobación final

1. El paisaje llena la pantalla sin bordes negros en 1440×900, 390×844 y 1920×1080, y el vídeo vuelve a empezar sin salto visible.
2. Sin errores en consola; en la pestaña de red se pide un solo vídeo (WebM en Chrome y Firefox; MP4 en Safari).
3. El paisaje no sale boca abajo ni estirado (`UNPACK_FLIP_Y_WEBGL` y `cubrir()`).
4. Las teclas 1/2/3 y los botones cambian de paleta con una transición de 0,9 s, y el `<em>` del titular cambia de color con ella.
5. En Ámbar el desierto parece de arena dorada al anochecer; en Glaciar, azul de luna; las estrellas siguen siendo blancas.
6. Mover el ratón desplaza el paisaje muy poco y con suavidad; nunca se ven los bordes del vídeo.
7. Cada pocos segundos cruza una estrella fugaz por el cielo, nunca sobre las dunas.
8. El botón de pausa detiene el vídeo, el grano y las estrellas fugaces, y cambia su etiqueta accesible.
9. Con movimiento reducido el fondo arranca quieto, sin animaciones de entrada.
10. Desactivando WebGL se ve el vídeo con el filtro de la paleta; bloqueando el vídeo, el póster.
11. Al hacer scroll fuera o cambiar de pestaña, el vídeo y el bucle se paran.
12. En móvil la barra se reorganiza (coordenadas arriba, muestras redondas sin texto) y no hay scroll horizontal.

Prueba en frío

La prueba en frío

Aún sin publicar para esta pieza

En la prueba en frío pegamos el prompt en Claude Sonnet 5.5, sin ayuda ni contexto, y comparamos lo que genera con la demo. Cuando esté hecha, aquí verás la captura, el veredicto y la fecha.

Paso a paso

Cómo usarlo en 4 pasos

  1. 01

    Personaliza

    Rellena el formulario de arriba con el nombre, los colores y los textos de tu cliente. Se meten en el prompt al copiar.
  2. 02

    Copia el Completo o el Lite

    El Completo, para Claude, ChatGPT, Gemini o Cursor. El Lite, más corto, para Lovable, Bolt o v0, que aceptan menos texto.
  3. 03

    Pega e itera

    Pégalo en una conversación nueva. Si quieres cambiar algo, pídelo con una frase corta:
    • «Pon la paleta en los colores de mi marca»
    • «Quita el titular y deja solo el fondo»
    • «Que no aparezcan estrellas fugaces»
    • «Oscurece el lado izquierdo para poner texto alineado a la izquierda»
  4. 04

    Publica

    Guarda el resultado como HTML (o en tu proyecto) y súbelo a Vercel, Netlify o tu hosting de siempre. Tienes los pasos en la guía.

Guías por herramientaClaudeChatGPTGeminiCursorLovableBoltv0

Licencia en claro

Qué puedes hacer con esta pieza

Todas las piezas incluyen licencia de uso comercial. Usa el resultado tal cual en tus productos, encargos freelance o webs de clientes, sin atribución.

Puedes

  • Usarlo en tus webs y en las de tus clientes, también si cobras por ellas.
  • Cambiarlo todo lo que quieras.
  • Publicar el resultado sin citarnos.

No puedes

  • Revender o regalar el prompt, la demo o el código como plantilla o biblioteca.
  • Compartir tu cuenta Pro.

Es la misma licencia para lo gratis y lo Pro. La tienes entera en licencia de uso.

Preguntas frecuentes

Lo que suele preguntarse

¿Tengo que renderizar el vídeo yo?
No. El prompt enlaza los vídeos ya hechos (1080p y 720p, WebM y MP4) y el póster. Si quieres, descárgalos y súbelos a tu propio hosting; solo cambia la constante ASSETS.
¿Puedo cambiar los colores sin otro vídeo?
Sí. El shader traduce la luz del vídeo a tres colores (sombra, medios y luces). Cambias esos tres valores y el paisaje entero cambia de paleta, con una transición suave.
¿Qué pasa si el navegador no tiene WebGL?
Se muestra el vídeo tal cual, con un filtro CSS que aproxima la paleta elegida. Si ni siquiera carga el vídeo, queda el póster y un degradado del mismo color.
¿Cuánto pesa?
El HTML ronda los 20 KB. El vídeo de 1080p pesa unos pocos megas y en móvil se carga el de 720p, que pesa bastante menos.
¿Qué diferencia hay entre el prompt Completo y el Lite?
El Completo (≈9671 tokens) describe la pieza con todo detalle y es el que mejor resultado da en Claude, ChatGPT, Gemini o Cursor. El Lite (≈2256 tokens) resume lo esencial para herramientas que aceptan menos texto, como Lovable, Bolt o v0.

Versión 1.0 · publicada el 30-09-2026

Cópialo desde aquí

Tu navegador no nos deja escribir en el portapapeles. El texto ya está seleccionado: pulsa Ctrl + C (o ⌘ + C) o el botón.