
Persiana
GratisCristal y luz · WebGL
La pieza
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.
Encaja bien en webs de
Antes de copiar
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.
Gratis y entero
Qué trae el prompt, por apartados
Tokens estimados para español (≈3,6 caracteres por token): sirven para orientarte, no son exactos.
# 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.app/assets/dunas-nocturnas';
```
| Archivo | Uso | Peso aprox. |
|---|---|---|
| `https://motionframe.app/assets/dunas-nocturnas/dunas-1080.webm` | VP9 1920×1080, primera opción | 0,7 MB |
| `https://motionframe.app/assets/dunas-nocturnas/dunas-1080.mp4` | H.264 1920×1080, respaldo | 4,2 MB |
| `https://motionframe.app/assets/dunas-nocturnas/dunas-720.webm` | VP9 1280×720 (lienzos de ≤ 760 px de alto reales) | 0,4 MB |
| `https://motionframe.app/assets/dunas-nocturnas/dunas-720.mp4` | H.264 1280×720, respaldo | 1,4 MB |
| `https://motionframe.app/assets/dunas-nocturnas/dunas-poster-1080.webp` | primer fotograma, 1920 px | 23 KB |
| `https://motionframe.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.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,4 MB (4,2 MB y 1,4 MB en MP4).
## 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
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
Personaliza
Copia el Completo o el Lite
Pega e itera
Publica
Licencia en claro
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
No puedes
Es la misma licencia para lo gratis y lo Pro. La tienes entera en licencia de uso.
Preguntas frecuentes
Versión 1.0 · publicada el 30-09-2026