morfeo 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@ import {H, W, backOut, clamp, easeIO, easeOut, lerp} from '../estilo';
7
7
  import {azar, cuando, formatoNumero, partesResaltadas, recurso, type CtxEscena} from './util';
8
8
  import {Particulas} from './fondo';
9
9
  import {Escena3D, Modelo3D} from './modelo3d';
10
+ import {Escenario} from './escenarios';
10
11
  import {componente, nombresComponentes} from './registro';
11
12
  import {Barra, Chat, Estado, Globo, ImagenCambia, Medidor, Noticia, Onda, Seguir, Votacion} from './widgets';
12
13
 
@@ -14,6 +15,9 @@ export type Capa = Record<string, any> & {tipo: string};
14
15
 
15
16
  const ENTRADA_DUR = 0.5;
16
17
 
18
+ // el personaje de una serie sigue en pantalla de escena en escena: no "reaparece" en cada una
19
+ const entradaPorDefecto = (c: Capa) => (c.tipo === 'personaje' ? 'ninguna' : 'escala');
20
+
17
21
  /** Posición, escala, giro y opacidad de una capa en el instante t (entrada, movimiento, salida). */
18
22
  function animar(c: Capa, ctx: CtxEscena, relativo: boolean) {
19
23
  const t = ctx.t;
@@ -26,7 +30,7 @@ function animar(c: Capa, ctx: CtxEscena, relativo: boolean) {
26
30
  let r = c.rotar ?? 0;
27
31
  let o = c.opacidad ?? 1;
28
32
  if (t < t0) return null; // con entrada 'ninguna' aparece de golpe en su momento
29
- switch (c.entrada ?? 'escala') {
33
+ switch (c.entrada ?? entradaPorDefecto(c)) {
30
34
  case 'aparecer':
31
35
  o *= easeOut(p);
32
36
  break;
@@ -185,6 +189,8 @@ const Contenido: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
185
189
  return <Tarjeta c={c} ctx={ctx} />;
186
190
  case 'telefono':
187
191
  return <Telefono c={c} ctx={ctx} />;
192
+ case 'ventana':
193
+ return <Ventana c={c} ctx={ctx} />;
188
194
  case 'forma':
189
195
  return <Forma c={c} ctx={ctx} />;
190
196
  case 'icono': {
@@ -214,6 +220,15 @@ const Contenido: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
214
220
  );
215
221
  case 'grupo':
216
222
  return <Grupo c={c} ctx={ctx} />;
223
+ case 'escenario': {
224
+ const {tipo, nombre, x, y, en, hasta, entrada, salida, z, ...props} = c;
225
+ const t0 = cuando(en, ctx, 0);
226
+ return (
227
+ <div style={{width: W, height: H}}>
228
+ <Escenario nombre={nombre} {...props} t={ctx.t - t0} dur={ctx.dur} cuando={(e: unknown) => cuando(e, ctx) - t0} />
229
+ </div>
230
+ );
231
+ }
217
232
  case 'escena3d':
218
233
  return <Escena3D c={c} ctx={ctx} />;
219
234
  case 'personaje':
@@ -260,7 +275,7 @@ const TextoCapa: React.FC<{c: Capa; ctx: CtxEscena; fuente: string}> = ({c, ctx,
260
275
  const vel = typeof c.escribir === 'number' ? c.escribir : 22; // letras por segundo
261
276
  const n = Math.max(0, Math.floor((ctx.t - t0) * vel));
262
277
  let cuenta = 0;
263
- texto = texto.replace(/[^*]/g, (ch) => (cuenta++ < n ? ch : ch === '\n' ? '\n' : ''));
278
+ texto = texto.replace(/[^*]/g, (ch) => (cuenta++ < n ? ch : ch === '\n' ? '\n' : '')).replace(/\*\*/g, '');
264
279
  }
265
280
  const color = c.color ?? T.colores.texto;
266
281
  const acento = c.acento ?? T.colores.acento;
@@ -398,7 +413,9 @@ const Telefono: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
398
413
  const borde = ancho * 0.035;
399
414
  return (
400
415
  <div style={{width: ancho, height: alto, background: '#0A0A0C', borderRadius: ancho * 0.13, padding: borde, boxShadow: '0 30px 60px rgba(0,0,0,0.45), inset 0 0 0 3px #2A2A30', boxSizing: 'border-box'}}>
401
- <div style={{position: 'relative', width: '100%', height: '100%', borderRadius: ancho * 0.1, overflow: 'hidden', background: c.pantalla ?? '#111'}}>
416
+ <div style={{position: 'relative', width: '100%', height: '100%', borderRadius: ancho * 0.1, overflow: 'hidden', background: c.pantalla ?? `linear-gradient(160deg, ${ctx.tema.colores.acento2}, ${ctx.tema.colores.acento} 60%, #111)`}}>
417
+ {/* sin imagen ni capas: una pantalla de app genérica en vez de un rectángulo negro */}
418
+ {!c.src && !c.capas?.length ? <PantallaGenerica ancho={ancho} t={ctx.t} /> : null}
402
419
  {c.src ? (
403
420
  /\.(mp4|webm|mov)$/i.test(c.src) ? (
404
421
  <OffthreadVideo src={recurso(c.src)} muted style={{width: '100%', height: '100%', objectFit: 'cover'}} />
@@ -418,6 +435,74 @@ const Telefono: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
418
435
  );
419
436
  };
420
437
 
438
+ /**
439
+ * Ventana circular (escotilla, lupa, mirilla): muestra una imagen o capas adentro, con borde metálico y remaches.
440
+ * {tipo: 'ventana', diametro: 640, src: 'interior.png'} o {tipo: 'ventana', capas: [...]} (coordenadas desde el centro)
441
+ * borde: 'metal' | 'ninguno' | '#color' · remaches · zoom: [inicio, fin] para acercar el contenido · fondo: color detrás
442
+ */
443
+ const Ventana: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
444
+ const d = c.diametro ?? 640;
445
+ const aro = c.borde === 'ninguno' ? 0 : c.grosor ?? Math.round(d * 0.06);
446
+ const metal = c.borde && c.borde !== 'metal' && c.borde !== 'ninguno' ? c.borde : null;
447
+ const t0 = cuando(c.en, ctx, 0);
448
+ const k = clamp((ctx.t - t0) / Math.max(0.5, ctx.dur - t0));
449
+ const zoom = Array.isArray(c.zoom) ? lerp(c.zoom[0], c.zoom[1], easeIO(k)) : c.zoom ?? 1;
450
+ const n = c.remaches === false ? 0 : c.remaches ?? 16;
451
+ const total = d + aro * 2;
452
+ return (
453
+ <div style={{position: 'relative', width: total, height: total}}>
454
+ <div
455
+ style={{
456
+ position: 'absolute',
457
+ inset: 0,
458
+ borderRadius: '50%',
459
+ background: metal ?? 'conic-gradient(from 210deg, #8C96A3, #E3E8EE, #7A8592, #C9D0D8, #6E7884, #DDE3EA, #8C96A3)',
460
+ boxShadow: '0 24px 50px rgba(0,0,0,0.45), inset 0 0 0 3px rgba(0,0,0,0.35)',
461
+ }}
462
+ />
463
+ <div style={{position: 'absolute', left: aro, top: aro, width: d, height: d, borderRadius: '50%', overflow: 'hidden', background: c.fondo ?? '#0C1A2A', boxShadow: 'inset 0 0 40px rgba(0,0,0,0.6)'}}>
464
+ <div style={{position: 'absolute', inset: 0, transform: `scale(${zoom})`}}>
465
+ {c.src ? <Img src={recurso(c.src)} style={{width: '100%', height: '100%', objectFit: 'cover'}} /> : null}
466
+ <div style={{position: 'absolute', left: '50%', top: '50%', width: 0, height: 0}}>
467
+ {(c.capas ?? []).map((h: Capa, i: number) => (
468
+ <CapaVista key={i} c={h} ctx={ctx} relativo i={i} />
469
+ ))}
470
+ </div>
471
+ </div>
472
+ {/* brillo del vidrio */}
473
+ <div style={{position: 'absolute', inset: 0, borderRadius: '50%', background: 'radial-gradient(circle at 30% 25%, rgba(255,255,255,0.22), transparent 40%)'}} />
474
+ </div>
475
+ {Array.from({length: n}, (_, i) => {
476
+ const a = (i / n) * Math.PI * 2;
477
+ const r = d / 2 + aro / 2;
478
+ return <div key={i} style={{position: 'absolute', left: total / 2 + Math.cos(a) * r - aro * 0.16, top: total / 2 + Math.sin(a) * r - aro * 0.16, width: aro * 0.32, height: aro * 0.32, borderRadius: '50%', background: 'radial-gradient(circle at 35% 35%, #F2F5F8, #6E7884)'}} />;
479
+ })}
480
+ </div>
481
+ );
482
+ };
483
+
484
+ /** Pantalla de relleno: barra de app, un video con botón de play y renglones de texto. */
485
+ const PantallaGenerica: React.FC<{ancho: number; t: number}> = ({ancho, t}) => {
486
+ const u = ancho / 100;
487
+ const pulso = 1 + 0.05 * Math.sin(t * 4);
488
+ return (
489
+ <div style={{position: 'absolute', inset: 0, padding: `${12 * u}px ${7 * u}px`, display: 'flex', flexDirection: 'column', gap: 4 * u}}>
490
+ <div style={{display: 'flex', alignItems: 'center', gap: 3 * u}}>
491
+ <div style={{width: 9 * u, height: 9 * u, borderRadius: '50%', background: 'rgba(255,255,255,0.85)'}} />
492
+ <div style={{flex: 1, height: 3 * u, borderRadius: 99, background: 'rgba(255,255,255,0.6)'}} />
493
+ </div>
494
+ <div style={{position: 'relative', height: 60 * u, borderRadius: 5 * u, background: 'rgba(0,0,0,0.28)', display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
495
+ <div style={{width: 18 * u, height: 18 * u, borderRadius: '50%', background: 'rgba(255,255,255,0.9)', display: 'flex', alignItems: 'center', justifyContent: 'center', transform: `scale(${pulso})`}}>
496
+ <div style={{width: 0, height: 0, borderStyle: 'solid', borderWidth: `${4.5 * u}px 0 ${4.5 * u}px ${7.5 * u}px`, borderColor: 'transparent transparent transparent #111', marginLeft: 1.5 * u}} />
497
+ </div>
498
+ </div>
499
+ {[90, 70, 80, 50].map((w, i) => (
500
+ <div key={i} style={{width: `${w}%`, height: 4 * u, borderRadius: 99, background: 'rgba(255,255,255,0.55)'}} />
501
+ ))}
502
+ </div>
503
+ );
504
+ };
505
+
421
506
  const Forma: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
422
507
  const T = ctx.tema;
423
508
  const color = c.color ?? T.colores.acento;
@@ -0,0 +1,157 @@
1
+ // Bosque de noche ilustrado, 1080×1920, por capas: cielo, estrellas, luna con halo, montañas,
2
+ // tres filas de pinos, colina con pasto y luciérnagas. Con `paneo` las capas se mueven a distinta
3
+ // velocidad (paralaje). La luna puede tener cara y cantar: luna: {cara: true, cantar: 'palabra', bajar: 'palabra'}.
4
+ import React from 'react';
5
+
6
+ type Props = {
7
+ t: number;
8
+ dur: number;
9
+ cuando: (en: unknown) => number;
10
+ paneo?: number;
11
+ luna?: {x?: number; y?: number; cara?: boolean; cantar?: string; bajar?: string; a?: [number, number]};
12
+ luciernagas?: boolean;
13
+ };
14
+
15
+ const W = 1080;
16
+ const H = 1920;
17
+ const clamp = (x: number) => Math.max(0, Math.min(1, x));
18
+ const suave = (x: number) => {
19
+ const c = clamp(x);
20
+ return c * c * (3 - 2 * c);
21
+ };
22
+ const azar = (n: number) => {
23
+ const x = Math.sin(n * 127.1) * 43758.5453;
24
+ return x - Math.floor(x);
25
+ };
26
+
27
+ /** Fila de pinos como un solo trazado (más liviano que muchos elementos). */
28
+ function pinos(base: number, alto: number, ancho: number, n: number, semilla: number) {
29
+ let d = '';
30
+ for (let i = 0; i < n; i++) {
31
+ const cx = (i / (n - 1)) * (W + 400) - 200 + (azar(semilla + i) - 0.5) * ancho * 0.6;
32
+ const h = alto * (0.75 + azar(semilla + i * 3) * 0.5);
33
+ const w = ancho * (0.8 + azar(semilla + i * 7) * 0.4);
34
+ // tres pisos de ramas
35
+ for (let p = 0; p < 3; p++) {
36
+ const top = base - h + p * h * 0.26;
37
+ const bot = base - h * 0.32 + p * h * 0.22;
38
+ const ww = w * (0.55 + p * 0.25);
39
+ d += `M${cx} ${top} L${cx + ww / 2} ${bot} L${cx - ww / 2} ${bot} Z `;
40
+ }
41
+ d += `M${cx - w * 0.06} ${base - h * 0.12} h${w * 0.12} v${h * 0.14} h${-w * 0.12} Z `;
42
+ }
43
+ return d;
44
+ }
45
+
46
+ export function Bosque({t, dur, cuando, paneo = 0, luna = {}, luciernagas = true}: Props) {
47
+ const k = suave(t / Math.max(1, dur));
48
+ const p = (f: number) => -paneo * f * k; // paralaje: lo lejano se mueve menos
49
+ // luna: posición (puede bajar) y canto
50
+ const lx0 = luna.x ?? 800;
51
+ const ly0 = luna.y ?? 470;
52
+ const kb = luna.bajar ? suave((t - cuando(luna.bajar)) / 1.8) : 0;
53
+ const lx = lx0 + ((luna.a?.[0] ?? lx0) - lx0) * kb;
54
+ const ly = ly0 + ((luna.a?.[1] ?? ly0) - ly0) * kb;
55
+ const canta = luna.cantar ? t >= cuando(luna.cantar) : false;
56
+ const boca = canta ? 6 + Math.abs(Math.sin(t * 9)) * 14 : 0;
57
+
58
+ return (
59
+ <svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
60
+ <defs>
61
+ <linearGradient id="cielo" x1="0" y1="0" x2="0" y2="1">
62
+ <stop offset="0" stopColor="#0B1033" />
63
+ <stop offset="0.5" stopColor="#252063" />
64
+ <stop offset="0.78" stopColor="#5B3C7E" />
65
+ <stop offset="1" stopColor="#8E5A86" />
66
+ </linearGradient>
67
+ <radialGradient id="halo">
68
+ <stop offset="0" stopColor="rgba(255,240,200,0.55)" />
69
+ <stop offset="1" stopColor="rgba(255,240,200,0)" />
70
+ </radialGradient>
71
+ <linearGradient id="colina" x1="0" y1="0" x2="0" y2="1">
72
+ <stop offset="0" stopColor="#1F3B3A" />
73
+ <stop offset="1" stopColor="#0E1E22" />
74
+ </linearGradient>
75
+ </defs>
76
+ <rect width={W} height={H} fill="url(#cielo)" />
77
+ {/* estrellas que titilan */}
78
+ {Array.from({length: 70}, (_, i) => {
79
+ const x = azar(i) * W;
80
+ const y = azar(i + 100) * 1100;
81
+ const r = 1.2 + azar(i + 200) * 2.4;
82
+ const o = 0.35 + 0.65 * Math.abs(Math.sin(t * (1 + azar(i + 300) * 2) + i));
83
+ return <circle key={i} cx={x + p(0.05)} cy={y} r={r} fill="#FFFFFF" opacity={o} />;
84
+ })}
85
+ {/* luna */}
86
+ <g transform={`translate(${lx + p(0.1)} ${ly})`}>
87
+ <circle r={260} fill="url(#halo)" />
88
+ <circle r={110} fill="#FFF4D6" />
89
+ <circle cx={-34} cy={-26} r={16} fill="rgba(210,190,150,0.45)" />
90
+ <circle cx={38} cy={30} r={22} fill="rgba(210,190,150,0.4)" />
91
+ <circle cx={18} cy={-48} r={9} fill="rgba(210,190,150,0.4)" />
92
+ {luna.cara ? (
93
+ <g>
94
+ {canta ? (
95
+ <>
96
+ <path d="M-42 -8 Q-30 -18 -18 -8" stroke="#6B5236" strokeWidth={6} fill="none" strokeLinecap="round" />
97
+ <path d="M18 -8 Q30 -18 42 -8" stroke="#6B5236" strokeWidth={6} fill="none" strokeLinecap="round" />
98
+ </>
99
+ ) : (
100
+ <>
101
+ <circle cx={-30} cy={-10} r={8} fill="#6B5236" />
102
+ <circle cx={30} cy={-10} r={8} fill="#6B5236" />
103
+ </>
104
+ )}
105
+ <ellipse cx={0} cy={34} rx={canta ? 14 : 16} ry={canta ? boca : 4} fill={canta ? '#7A3B3B' : '#6B5236'} />
106
+ <circle cx={-58} cy={18} r={12} fill="rgba(255,150,150,0.45)" />
107
+ <circle cx={58} cy={18} r={12} fill="rgba(255,150,150,0.45)" />
108
+ </g>
109
+ ) : null}
110
+ </g>
111
+ {/* notas musicales cuando canta */}
112
+ {canta
113
+ ? Array.from({length: 6}, (_, i) => {
114
+ const fase = ((t - cuando(luna.cantar) + i * 0.45) % 2.7) / 2.7;
115
+ const x = lx - 160 - fase * 260 + Math.sin(fase * 9 + i) * 40;
116
+ const y = ly + 60 + fase * 420;
117
+ return (
118
+ <text key={i} x={x} y={y} fontSize={70} fill="#FFE9A8" opacity={Math.sin(fase * Math.PI)} fontFamily="sans-serif">
119
+ {i % 2 ? '♪' : '♫'}
120
+ </text>
121
+ );
122
+ })
123
+ : null}
124
+ {/* montañas lejanas */}
125
+ <path transform={`translate(${p(0.15)} 0)`} d="M-200 1250 L80 980 L300 1150 L560 920 L820 1120 L1040 960 L1300 1180 L1300 1500 L-200 1500 Z" fill="#2E2A5A" opacity={0.9} />
126
+ {/* pinos lejanos, medios y cercanos */}
127
+ <path transform={`translate(${p(0.3)} 0)`} d={pinos(1330, 300, 150, 12, 1)} fill="#263A55" />
128
+ <path transform={`translate(${p(0.55)} 0)`} d={pinos(1420, 380, 200, 9, 20)} fill="#1A2E3F" />
129
+ {/* colina */}
130
+ <path transform={`translate(${p(0.8)} 0)`} d="M-300 1500 Q300 1380 700 1460 Q1050 1520 1400 1440 L1400 1920 L-300 1920 Z" fill="url(#colina)" />
131
+ {/* pasto */}
132
+ <g transform={`translate(${p(0.8)} 0)`}>
133
+ {Array.from({length: 60}, (_, i) => {
134
+ const x = -200 + i * 26;
135
+ const y = 1470 + Math.sin(i * 0.7) * 22;
136
+ const viento = Math.sin(t * 2 + i * 0.4) * 5;
137
+ return <path key={i} d={`M${x} ${y + 30} Q${x + viento} ${y + 10} ${x + 4 + viento * 1.6} ${y - 6}`} stroke="#2E5A4E" strokeWidth={5} fill="none" strokeLinecap="round" />;
138
+ })}
139
+ </g>
140
+ <path transform={`translate(${p(1)} 0)`} d={pinos(1950, 620, 320, 5, 40)} fill="#0C1A24" />
141
+ {/* luciérnagas */}
142
+ {luciernagas
143
+ ? Array.from({length: 14}, (_, i) => {
144
+ const x = azar(i + 500) * W + Math.sin(t * 0.8 + i) * 40;
145
+ const y = 1250 + azar(i + 600) * 450 + Math.cos(t * 0.6 + i) * 30;
146
+ const o = 0.3 + 0.7 * Math.abs(Math.sin(t * 1.7 + i * 2));
147
+ return (
148
+ <g key={i} opacity={o}>
149
+ <circle cx={x} cy={y} r={14} fill="rgba(255,236,120,0.25)" />
150
+ <circle cx={x} cy={y} r={4} fill="#FFF3A0" />
151
+ </g>
152
+ );
153
+ })
154
+ : null}
155
+ </svg>
156
+ );
157
+ }
@@ -0,0 +1,103 @@
1
+ // Cuarto de Neto (serie). Dos partes para meter al personaje en medio:
2
+ // {tipo: 'componente', nombre: 'Cuarto'} pared, ventana, póster, calendario
3
+ // {tipo: 'componente', nombre: 'Cuarto', props: {parte: 'frente'}} escritorio, monitor, teclado RGB y ramen
4
+ import React from 'react';
5
+
6
+ type Props = {t: number; parte?: 'fondo' | 'frente'; dia?: number};
7
+
8
+ const W = 1080;
9
+ const H = 1920;
10
+
11
+ function Fondo({t, dia}: {t: number; dia?: number}) {
12
+ return (
13
+ <svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
14
+ <defs>
15
+ <pattern id="rayas" width="90" height="10" patternUnits="userSpaceOnUse">
16
+ <rect width="90" height="10" fill="#5B3FA0" />
17
+ <rect width="45" height="10" fill="#6447AE" />
18
+ </pattern>
19
+ <linearGradient id="noche" x1="0" y1="0" x2="0" y2="1">
20
+ <stop offset="0" stopColor="#0E1840" />
21
+ <stop offset="1" stopColor="#2D2A6E" />
22
+ </linearGradient>
23
+ </defs>
24
+ <rect width={W} height={H} fill="url(#rayas)" />
25
+ {/* zoclo y piso */}
26
+ <rect y={1520} width={W} height={400} fill="#3B2A63" />
27
+ <rect y={1510} width={W} height={18} fill="#2A1D4A" />
28
+ {/* ventana */}
29
+ <g transform="translate(70 360)">
30
+ <rect width={420} height={520} rx={10} fill="url(#noche)" stroke="#2A1D4A" strokeWidth={18} />
31
+ <circle cx={300} cy={130} r={50} fill="#FFF4D6" />
32
+ <circle cx={322} cy={118} r={46} fill="#141E4E" />
33
+ {Array.from({length: 14}, (_, i) => (
34
+ <circle key={i} cx={40 + ((i * 97) % 340)} cy={60 + ((i * 53) % 400)} r={3} fill="#fff" opacity={0.4 + 0.6 * Math.abs(Math.sin(t * 1.5 + i))} />
35
+ ))}
36
+ <rect x={200} y={0} width={18} height={520} fill="#2A1D4A" />
37
+ <rect x={0} y={250} width={420} height={18} fill="#2A1D4A" />
38
+ {/* cortinas que se mecen */}
39
+ <path d={`M-40 -20 Q${30 + Math.sin(t) * 8} 260 -10 560 L-60 560 L-60 -20 Z`} fill="#C0304A" />
40
+ <path d={`M460 -20 Q${390 + Math.sin(t + 1) * 8} 260 430 560 L480 560 L480 -20 Z`} fill="#C0304A" />
41
+ </g>
42
+ {/* póster de Ari */}
43
+ <g transform="translate(700 330) rotate(4)">
44
+ <rect width={300} height={380} rx={8} fill="#FFFFFF" />
45
+ <rect x={18} y={18} width={264} height={290} fill="#FF9EC7" />
46
+ <circle cx={150} cy={150} r={70} fill="#FFE1EF" />
47
+ <path d="M150 120 l-28 -28 a20 20 0 0 1 28 -18 a20 20 0 0 1 28 18z" fill="#FF3B7F" transform={`translate(0 ${Math.sin(t * 3) * 4})`} />
48
+ <text x={150} y={355} textAnchor="middle" fontSize={44} fontWeight={900} fill="#FF3B7F" fontFamily="Montserrat, sans-serif">
49
+ ARI
50
+ </text>
51
+ <rect x={120} y={-14} width={60} height={26} fill="rgba(255,255,255,0.7)" />
52
+ </g>
53
+ {/* calendario con días tachados */}
54
+ <g transform="translate(560 780)">
55
+ <rect width={220} height={230} rx={8} fill="#FFFFFF" />
56
+ <rect width={220} height={56} rx={8} fill="#E5484D" />
57
+ <text x={110} y={40} textAnchor="middle" fontSize={32} fontWeight={900} fill="#fff" fontFamily="Montserrat, sans-serif">
58
+ {dia != null ? `DÍA ${dia}` : 'NOV'}
59
+ </text>
60
+ {Array.from({length: 20}, (_, i) => (
61
+ <text key={i} x={22 + (i % 5) * 42} y={90 + Math.floor(i / 5) * 38} fontSize={30} fontWeight={900} fill="#E5484D" fontFamily="sans-serif">
62
+ ✕
63
+ </text>
64
+ ))}
65
+ </g>
66
+ </svg>
67
+ );
68
+ }
69
+
70
+ function Frente({t}: {t: number}) {
71
+ const colores = ['#FF4D6D', '#FFD60A', '#4CC9F0', '#80ED99', '#B517FF', '#FF8C42'];
72
+ return (
73
+ <svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
74
+ {/* luz del monitor sobre el escritorio */}
75
+ <ellipse cx={540} cy={1600} rx={520} ry={120} fill="rgba(120,200,255,0.12)" />
76
+ {/* escritorio */}
77
+ <rect x={-20} y={1640} width={1120} height={60} rx={10} fill="#2B2238" />
78
+ <rect x={-20} y={1690} width={1120} height={260} fill="#1E1828" />
79
+ {/* teclado RGB */}
80
+ <g transform="translate(250 1590)">
81
+ <rect width={580} height={70} rx={14} fill="#15121C" />
82
+ {Array.from({length: 24}, (_, i) => (
83
+ <rect key={i} x={14 + (i % 12) * 46} y={10 + Math.floor(i / 12) * 28} width={40} height={22} rx={5} fill={colores[Math.floor(i + t * 6) % colores.length]} opacity={0.9} />
84
+ ))}
85
+ </g>
86
+ {/* ramen */}
87
+ <g transform="translate(70 1500)">
88
+ <path d="M0 60 L130 60 L112 150 L18 150 Z" fill="#FFFFFF" stroke="#D9D3C7" strokeWidth={4} />
89
+ <rect x={10} y={80} width={110} height={26} fill="#E5484D" />
90
+ <path d={`M50 60 Q${44 + Math.sin(t * 2) * 6} 20 56 -10`} stroke="rgba(255,255,255,0.5)" strokeWidth={6} fill="none" strokeLinecap="round" />
91
+ <rect x={84} y={-30} width={8} height={110} rx={4} fill="#C99A5B" transform="rotate(14 88 30)" />
92
+ <rect x={98} y={-30} width={8} height={110} rx={4} fill="#C99A5B" transform="rotate(20 102 30)" />
93
+ </g>
94
+ {/* mouse */}
95
+ <ellipse cx={920} cy={1630} rx={38} ry={22} fill="#15121C" />
96
+ <rect x={916} y={1612} width={8} height={14} rx={3} fill={colores[Math.floor(t * 4) % colores.length]} />
97
+ </svg>
98
+ );
99
+ }
100
+
101
+ export function Cuarto({t, parte = 'fondo', dia}: Props) {
102
+ return parte === 'frente' ? <Frente t={t} /> : <Fondo t={t} dia={dia} />;
103
+ }
@@ -0,0 +1,163 @@
1
+ // Escenarios nocturnos para historias, 1080×1920:
2
+ // lugar: 'departamento' sala oscura: ventana con ciudad, sillón, lámpara y tele que parpadea
3
+ // lugar: 'tunel' túnel del metro: arcos, rieles, luces que pasan y el tren que llega (llegar: 'palabra')
4
+ // lugar: 'pasillo' pasillo con la puerta: luz bajo la puerta y golpes que la sacuden (golpe: 'palabra')
5
+ import React from 'react';
6
+
7
+ type Props = {t: number; cuando: (en: unknown) => number; lugar?: 'departamento' | 'tunel' | 'pasillo'; llegar?: string; golpe?: string};
8
+
9
+ const W = 1080;
10
+ const H = 1920;
11
+ const clamp = (x: number) => Math.max(0, Math.min(1, x));
12
+ const suave = (x: number) => {
13
+ const c = clamp(x);
14
+ return c * c * (3 - 2 * c);
15
+ };
16
+ const azar = (n: number) => {
17
+ const x = Math.sin(n * 91.7) * 43758.5453;
18
+ return x - Math.floor(x);
19
+ };
20
+
21
+ function Departamento({t}: {t: number}) {
22
+ const tele = 0.55 + 0.45 * Math.abs(Math.sin(t * 7) * Math.sin(t * 3.3));
23
+ return (
24
+ <svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
25
+ <rect width={W} height={H} fill="#121A2B" />
26
+ <rect y={1480} width={W} height={440} fill="#0C111C" />
27
+ {/* ventana con ciudad */}
28
+ <g transform="translate(110 520)">
29
+ <rect width={460} height={560} fill="#1B2A4A" stroke="#0A0F1A" strokeWidth={16} />
30
+ {Array.from({length: 8}, (_, i) => {
31
+ const x = 10 + i * 58;
32
+ const h = 160 + azar(i) * 260;
33
+ return (
34
+ <g key={i}>
35
+ <rect x={x} y={560 - h} width={50} height={h} fill="#0E1730" />
36
+ {Array.from({length: 10}, (_, j) =>
37
+ azar(i * 10 + j) > 0.55 ? <rect key={j} x={x + 8 + (j % 3) * 14} y={560 - h + 14 + Math.floor(j / 3) * 26} width={8} height={12} fill="#FFD27A" opacity={0.5 + 0.5 * Math.abs(Math.sin(t + i + j))} /> : null,
38
+ )}
39
+ </g>
40
+ );
41
+ })}
42
+ <rect x={222} width={16} height={560} fill="#0A0F1A" />
43
+ </g>
44
+ {/* luz de la lámpara */}
45
+ <circle cx={880} cy={980} r={420} fill="rgba(255,190,110,0.12)" />
46
+ <rect x={872} y={900} width={16} height={600} fill="#2A2420" />
47
+ <path d="M800 900 L960 900 L920 800 L840 800 Z" fill="#E8B35C" />
48
+ <ellipse cx={880} cy={1500} rx={90} ry={16} fill="#1C1814" />
49
+ {/* tele que parpadea */}
50
+ <rect x={600} y={1180} width={260} height={160} rx={8} fill="#0A0F1A" />
51
+ <rect x={612} y={1192} width={236} height={136} fill={`rgba(120,170,255,${0.35 * tele})`} />
52
+ <circle cx={730} cy={1260} r={360} fill={`rgba(120,170,255,${0.06 * tele})`} />
53
+ {/* sillón */}
54
+ <g transform="translate(140 1300)">
55
+ <rect width={560} height={180} rx={40} fill="#3A2E3A" />
56
+ <rect y={-120} width={560} height={150} rx={40} fill="#45374A" />
57
+ <rect x={-30} y={-60} width={90} height={250} rx={36} fill="#3A2E3A" />
58
+ <rect x={500} y={-60} width={90} height={250} rx={36} fill="#3A2E3A" />
59
+ </g>
60
+ </svg>
61
+ );
62
+ }
63
+
64
+ function Tunel({t, tTren}: {t: number; tTren: number}) {
65
+ const k = suave((t - tTren) / 2.4); // el tren se acerca
66
+ const escala = 0.2 + k * 0.85;
67
+ return (
68
+ <svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
69
+ <defs>
70
+ <radialGradient id="fondoTunel" cx="0.5" cy="0.48" r="0.6">
71
+ <stop offset="0" stopColor="#2C3446" />
72
+ <stop offset="1" stopColor="#07090E" />
73
+ </radialGradient>
74
+ </defs>
75
+ <rect width={W} height={H} fill="url(#fondoTunel)" />
76
+ {/* arcos del túnel hacia el fondo */}
77
+ {Array.from({length: 7}, (_, i) => {
78
+ const d = ((i + (t * 0.6) % 1) / 7);
79
+ const s = 0.15 + (1 - d) * 1.1;
80
+ return <ellipse key={i} cx={540} cy={900} rx={520 * s} ry={600 * s} fill="none" stroke="#3B4560" strokeWidth={10 * s} opacity={0.25 + (1 - d) * 0.6} />;
81
+ })}
82
+ {/* luces de pared que pasan */}
83
+ {Array.from({length: 6}, (_, i) => {
84
+ const d = ((i / 6 + t * 0.35) % 1);
85
+ const s = 0.1 + d * 1.2;
86
+ return (
87
+ <g key={i} opacity={d}>
88
+ <circle cx={540 - 470 * s} cy={900 - 120 * s} r={10 * s + 3} fill="#FFE29A" />
89
+ <circle cx={540 + 470 * s} cy={900 - 120 * s} r={10 * s + 3} fill="#FFE29A" />
90
+ </g>
91
+ );
92
+ })}
93
+ {/* rieles */}
94
+ <path d="M520 960 L250 1920 M560 960 L830 1920" stroke="#5A6378" strokeWidth={14} />
95
+ {Array.from({length: 10}, (_, i) => {
96
+ const d = (i / 10 + t * 0.5) % 1;
97
+ const y = 960 + d * d * 960;
98
+ const a = 30 + d * 420;
99
+ return <rect key={i} x={540 - a} y={y} width={a * 2} height={6 + d * 18} fill="#2A2F3C" />;
100
+ })}
101
+ {/* tren que llega con faros */}
102
+ {t >= tTren ? (
103
+ <g transform={`translate(540 ${930 + k * 160}) scale(${escala})`}>
104
+ <circle r={700} fill="rgba(255,240,200,0.08)" />
105
+ <rect x={-330} y={-380} width={660} height={700} rx={70} fill="#D9DEE8" />
106
+ <rect x={-280} y={-310} width={560} height={240} rx={26} fill="#1A2233" />
107
+ <rect x={-330} y={-40} width={660} height={60} fill="#E5484D" />
108
+ <text x={0} y={190} textAnchor="middle" fontSize={130} fontWeight={900} fill="#1A2233" fontFamily="Montserrat, sans-serif">
109
+ B
110
+ </text>
111
+ <circle cx={-210} cy={220} r={46} fill="#FFF6C8" />
112
+ <circle cx={210} cy={220} r={46} fill="#FFF6C8" />
113
+ <circle cx={-210} cy={220} r={120} fill="rgba(255,246,200,0.25)" />
114
+ <circle cx={210} cy={220} r={120} fill="rgba(255,246,200,0.25)" />
115
+ </g>
116
+ ) : null}
117
+ </svg>
118
+ );
119
+ }
120
+
121
+ function Pasillo({t, tGolpe}: {t: number; tGolpe: number}) {
122
+ // tres golpes: la puerta se sacude y la luz de abajo parpadea
123
+ const g = t - tGolpe;
124
+ const golpe = g >= 0 && g < 1.6 ? [0, 0.45, 0.9].reduce((s, d) => s + Math.max(0, 1 - Math.abs(g - d) * 8), 0) : 0;
125
+ const sacudida = Math.sin(t * 80) * 10 * golpe;
126
+ const luz = 0.6 + 0.4 * Math.abs(Math.sin(t * 2.3)) - golpe * 0.3;
127
+ return (
128
+ <svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
129
+ <rect width={W} height={H} fill="#151B26" />
130
+ {/* paredes en perspectiva */}
131
+ <path d="M0 0 L250 380 L250 1500 L0 1920 Z" fill="#1C2433" />
132
+ <path d="M1080 0 L830 380 L830 1500 L1080 1920 Z" fill="#1C2433" />
133
+ <path d="M250 1500 L830 1500 L1080 1920 L0 1920 Z" fill="#2A2018" />
134
+ {/* tablas del piso */}
135
+ {Array.from({length: 7}, (_, i) => (
136
+ <path key={i} d={`M${250 + i * 97} 1500 L${-60 + i * 200} 1920`} stroke="#1E1610" strokeWidth={4} />
137
+ ))}
138
+ {/* cuadros en la pared */}
139
+ <rect x={60} y={700} width={120} height={160} fill="#2A3346" stroke="#3A4560" strokeWidth={8} transform="skewY(18)" />
140
+ <rect x={900} y={980} width={120} height={160} fill="#2A3346" stroke="#3A4560" strokeWidth={8} transform="skewY(-18)" />
141
+ {/* puerta */}
142
+ <g transform={`translate(${sacudida} 0)`}>
143
+ <rect x={320} y={540} width={440} height={960} fill="#0F1520" />
144
+ <rect x={340} y={560} width={400} height={930} fill="#22314A" stroke="#33466A" strokeWidth={10} />
145
+ <rect x={380} y={610} width={320} height={360} fill="none" stroke="#33466A" strokeWidth={8} />
146
+ <rect x={380} y={1030} width={320} height={400} fill="none" stroke="#33466A" strokeWidth={8} />
147
+ <circle cx={690} cy={1060} r={18} fill="#D9B45A" />
148
+ <circle cx={540} cy={760} r={14} fill="#0A0E16" stroke="#3A4560" strokeWidth={5} />
149
+ </g>
150
+ {/* luz por debajo de la puerta */}
151
+ <rect x={340} y={1488} width={400} height={12} fill="#FFD66B" opacity={luz} />
152
+ <path d={`M340 1500 L740 1500 L${900} 1920 L${180} 1920 Z`} fill={`rgba(255,214,107,${0.12 * luz})`} />
153
+ {/* sombra de pies bajo la puerta */}
154
+ {g > 0 ? <rect x={500} y={1489} width={80} height={10} fill="#0A0A0A" opacity={0.85} /> : null}
155
+ </svg>
156
+ );
157
+ }
158
+
159
+ export function Noche({t, cuando, lugar = 'departamento', llegar, golpe}: Props) {
160
+ if (lugar === 'tunel') return <Tunel t={t} tTren={llegar ? cuando(llegar) : 0.3} />;
161
+ if (lugar === 'pasillo') return <Pasillo t={t} tGolpe={golpe ? cuando(golpe) : 1} />;
162
+ return <Departamento t={t} />;
163
+ }
@@ -0,0 +1,26 @@
1
+ // Escenarios ilustrados incluidos (1080×1920), para que un personaje nunca quede sobre un fondo vacío.
2
+ // fondo: {tipo: 'escenario', nombre: 'cuarto'} como fondo de escena (o del estilo)
3
+ // {tipo: 'escenario', nombre: 'cuarto', parte: 'frente'} como capa (por ejemplo, el escritorio delante del personaje)
4
+ // Todos se animan con el tiempo de la escena (estrellas, cortinas, luces, tren, golpes en la puerta...).
5
+ import React from 'react';
6
+ import {Bosque} from './Bosque';
7
+ import {Cuarto} from './Cuarto';
8
+ import {Noche} from './Noche';
9
+
10
+ type P = {t: number; dur: number; cuando: (en: unknown) => number} & Record<string, any>;
11
+
12
+ export const ESCENARIOS: Record<string, React.FC<P>> = {
13
+ bosque: (p) => <Bosque {...p} />,
14
+ cuarto: (p) => <Cuarto {...p} />,
15
+ departamento: (p) => <Noche {...p} lugar="departamento" />,
16
+ tunel: (p) => <Noche {...p} lugar="tunel" />,
17
+ pasillo: (p) => <Noche {...p} lugar="pasillo" />,
18
+ };
19
+
20
+ export const NOMBRES_ESCENARIOS = Object.keys(ESCENARIOS);
21
+
22
+ export const Escenario: React.FC<P & {nombre: string}> = ({nombre, ...p}) => {
23
+ const Comp = ESCENARIOS[nombre];
24
+ if (!Comp) throw new Error(`Escenario "${nombre}" no existe. Hay: ${NOMBRES_ESCENARIOS.join(', ')}`);
25
+ return <Comp {...p} />;
26
+ };
@@ -4,9 +4,10 @@ import React from 'react';
4
4
  import {AbsoluteFill, Img, OffthreadVideo} from 'remotion';
5
5
  import {H, W} from '../estilo';
6
6
  import type {Fondo as TFondo, Particulas as TParticulas, Tema} from './tema';
7
- import {azar, recurso} from './util';
7
+ import {azar, cuando, recurso, type CtxEscena} from './util';
8
+ import {Escenario} from './escenarios';
8
9
 
9
- const Base: React.FC<{f: TFondo}> = ({f}) => {
10
+ const Base: React.FC<{f: TFondo; ctx?: CtxEscena}> = ({f, ctx}) => {
10
11
  switch (f.tipo) {
11
12
  case 'color':
12
13
  return <AbsoluteFill style={{background: f.color}} />;
@@ -19,6 +20,17 @@ const Base: React.FC<{f: TFondo}> = ({f}) => {
19
20
  {f.oscurecer ? <AbsoluteFill style={{background: `rgba(0,0,0,${f.oscurecer})`}} /> : null}
20
21
  </AbsoluteFill>
21
22
  );
23
+ case 'escenario': {
24
+ // escenario ilustrado animado: usa el tiempo de la escena (sin ctx, por ejemplo en la miniatura de otro lado, queda quieto)
25
+ const {tipo, nombre, oscurecer, ...props} = f as any;
26
+ const c = ctx ?? ({t: 0, dur: 1, habla: [0, 1], palabras: [], semilla: '', tema: undefined as any} as CtxEscena);
27
+ return (
28
+ <AbsoluteFill>
29
+ <Escenario nombre={nombre} {...props} t={c.t} dur={c.dur} cuando={(en: unknown) => cuando(en, c)} />
30
+ {oscurecer ? <AbsoluteFill style={{background: `rgba(0,0,0,${oscurecer})`}} /> : null}
31
+ </AbsoluteFill>
32
+ );
33
+ }
22
34
  case 'video':
23
35
  return (
24
36
  <AbsoluteFill>
@@ -110,8 +122,8 @@ export const Particulas: React.FC<{p: TParticulas; t: number; semilla: string}>
110
122
  };
111
123
 
112
124
  /** Ruido fino (película) o papel: un SVG fractal estable. */
113
- const Textura: React.FC<{tipo: 'papel' | 'grano'}> = ({tipo}) => (
114
- <AbsoluteFill style={{opacity: tipo === 'papel' ? 0.35 : 0.12, mixBlendMode: 'multiply', pointerEvents: 'none'}}>
125
+ export const Textura: React.FC<{tipo: 'papel' | 'grano'}> = ({tipo}) => (
126
+ <AbsoluteFill style={{opacity: tipo === 'papel' ? 0.22 : 0.1, mixBlendMode: 'multiply', pointerEvents: 'none'}}>
115
127
  <svg width={W} height={H}>
116
128
  <filter id={`tx-${tipo}`}>
117
129
  <feTurbulence type="fractalNoise" baseFrequency={tipo === 'papel' ? 0.9 : 1.6} numOctaves={tipo === 'papel' ? 3 : 2} seed={3} />
@@ -123,12 +135,12 @@ const Textura: React.FC<{tipo: 'papel' | 'grano'}> = ({tipo}) => (
123
135
  );
124
136
 
125
137
  /** El fondo completo de una escena (el de la escena gana sobre el del estilo). */
126
- export const FondoTema: React.FC<{tema: Tema; fondo?: TFondo; t: number; semilla: string}> = ({tema, fondo, t, semilla}) => {
138
+ export const FondoTema: React.FC<{tema: Tema; fondo?: TFondo; t: number; semilla: string; ctx?: CtxEscena}> = ({tema, fondo, t, semilla, ctx}) => {
127
139
  const f = fondo ?? tema.fondo;
128
140
  const foco = tema.foco;
129
141
  return (
130
142
  <AbsoluteFill>
131
- <Base f={f} />
143
+ <Base f={f} ctx={ctx} />
132
144
  {tema.rayos ? <Rayos color={tema.rayos.color} fuerza={tema.rayos.fuerza ?? 1} t={t} /> : null}
133
145
  {foco ? (
134
146
  <AbsoluteFill
@@ -139,7 +151,6 @@ export const FondoTema: React.FC<{tema: Tema; fondo?: TFondo; t: number; semilla
139
151
  }}
140
152
  />
141
153
  ) : null}
142
- {tema.textura && tema.textura !== 'ninguna' ? <Textura tipo={tema.textura} /> : null}
143
154
  {tema.particulas ? <Particulas p={tema.particulas} t={t} semilla={semilla} /> : null}
144
155
  </AbsoluteFill>
145
156
  );