morfeo 0.0.0-stage → 0.1.1

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.
Files changed (51) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +210 -2
  3. package/assets/fuentes/Anton.ttf +0 -0
  4. package/assets/fuentes/JetBrainsMono.ttf +0 -0
  5. package/assets/fuentes/Montserrat.ttf +0 -0
  6. package/bin/morfeo.mjs +184 -0
  7. package/bin/postinstall.mjs +10 -0
  8. package/index.d.ts +250 -0
  9. package/lib/config.mjs +284 -0
  10. package/lib/entorno.mjs +93 -0
  11. package/lib/guion.mjs +313 -0
  12. package/lib/imagenes.mjs +99 -0
  13. package/lib/index.mjs +15 -0
  14. package/lib/instalar.mjs +193 -0
  15. package/lib/navegador.mjs +64 -0
  16. package/lib/personajes.mjs +94 -0
  17. package/lib/render.mjs +312 -0
  18. package/lib/voces.mjs +122 -0
  19. package/lib/whisper.mjs +45 -0
  20. package/package.json +84 -5
  21. package/plantilla/assets/.gitkeep +0 -0
  22. package/plantilla/env.example +7 -0
  23. package/plantilla/escenas/Barras.tsx +15 -0
  24. package/plantilla/gitignore +4 -0
  25. package/plantilla/guiones/cachalote.mjs +24 -0
  26. package/plantilla/guiones/tutorial.mjs +16 -0
  27. package/plantilla/morfeo.config.mjs +43 -0
  28. package/plantilla/package.json +13 -0
  29. package/plantilla/videos.mjs +25 -0
  30. package/python/musica.py +221 -0
  31. package/python/preparar.py +268 -0
  32. package/python/requirements.txt +5 -0
  33. package/remotion/Root.tsx +35 -0
  34. package/remotion/Short.tsx +278 -0
  35. package/remotion/escenas.tsx +430 -0
  36. package/remotion/estilo.ts +73 -0
  37. package/remotion/iconos.ts +326 -0
  38. package/remotion/index.ts +4 -0
  39. package/remotion/motor/Video.tsx +121 -0
  40. package/remotion/motor/capas.tsx +622 -0
  41. package/remotion/motor/fondo.tsx +148 -0
  42. package/remotion/motor/modelo3d.tsx +252 -0
  43. package/remotion/motor/plantillas.tsx +125 -0
  44. package/remotion/motor/registro.ts +11 -0
  45. package/remotion/motor/subtitulos.tsx +137 -0
  46. package/remotion/motor/tema.ts +207 -0
  47. package/remotion/motor/util.ts +66 -0
  48. package/remotion/motor/widgets.tsx +248 -0
  49. package/remotion/piezas.tsx +181 -0
  50. package/remotion/tipos.ts +43 -0
  51. package/remotion/tres/Capa3D.tsx +153 -0
@@ -0,0 +1,622 @@
1
+ // Motor de capas: cada escena es una lista de capas (imagen, texto, contador, chip, tarjeta, teléfono,
2
+ // forma, grupo...) con su posición y sus animaciones. Las coordenadas son del lienzo 1080×1920.
3
+ import React from 'react';
4
+ import {Img, OffthreadVideo} from 'remotion';
5
+ import {Avatar, Icono, tamIcono, useMarca} from '../piezas';
6
+ import {H, W, backOut, clamp, easeIO, easeOut, lerp} from '../estilo';
7
+ import {azar, cuando, formatoNumero, partesResaltadas, recurso, type CtxEscena} from './util';
8
+ import {Particulas} from './fondo';
9
+ import {Escena3D, Modelo3D} from './modelo3d';
10
+ import {componente, nombresComponentes} from './registro';
11
+ import {Barra, Chat, Estado, Globo, ImagenCambia, Medidor, Noticia, Onda, Seguir, Votacion} from './widgets';
12
+
13
+ export type Capa = Record<string, any> & {tipo: string};
14
+
15
+ const ENTRADA_DUR = 0.5;
16
+
17
+ // el personaje de una serie sigue en pantalla de escena en escena: no "reaparece" en cada una
18
+ const entradaPorDefecto = (c: Capa) => (c.tipo === 'personaje' ? 'ninguna' : 'escala');
19
+
20
+ /** Posición, escala, giro y opacidad de una capa en el instante t (entrada, movimiento, salida). */
21
+ function animar(c: Capa, ctx: CtxEscena, relativo: boolean) {
22
+ const t = ctx.t;
23
+ const t0 = cuando(c.en, ctx, 0);
24
+ const dur = c.duracionEntrada ?? ENTRADA_DUR;
25
+ const p = dur > 0 ? clamp((t - t0) / dur) : t >= t0 ? 1 : 0;
26
+ let x = c.x ?? (relativo ? 0 : W / 2);
27
+ let y = c.y ?? (relativo ? 0 : H / 2);
28
+ let s = c.escala ?? 1;
29
+ let r = c.rotar ?? 0;
30
+ let o = c.opacidad ?? 1;
31
+ if (t < t0) return null; // con entrada 'ninguna' aparece de golpe en su momento
32
+ switch (c.entrada ?? entradaPorDefecto(c)) {
33
+ case 'aparecer':
34
+ o *= easeOut(p);
35
+ break;
36
+ case 'escala':
37
+ s *= easeOut(p);
38
+ o *= clamp(p * 3);
39
+ break;
40
+ case 'rebote':
41
+ s *= backOut(p);
42
+ break;
43
+ case 'deslizar-izq':
44
+ x -= (1 - easeOut(p)) * W;
45
+ break;
46
+ case 'deslizar-der':
47
+ x += (1 - easeOut(p)) * W;
48
+ break;
49
+ case 'deslizar-arriba':
50
+ y += (1 - easeOut(p)) * H * 0.6;
51
+ break;
52
+ case 'deslizar-abajo':
53
+ y -= (1 - easeOut(p)) * H * 0.6;
54
+ break;
55
+ case 'caer':
56
+ y -= (1 - backOut(p, 1.2)) * 700;
57
+ o *= clamp(p * 4);
58
+ break;
59
+ case 'girar':
60
+ r += (1 - easeOut(p)) * -200;
61
+ s *= easeOut(p);
62
+ break;
63
+ case 'subir':
64
+ y += (1 - easeOut(p)) * 80;
65
+ o *= easeOut(p);
66
+ break;
67
+ }
68
+ // movimiento: mover: {a: [x, y], en?, duracion?, curva?: 'suave' | 'lineal'}
69
+ const mv = c.mover;
70
+ if (mv?.a) {
71
+ const m0 = cuando(mv.en, ctx, t0);
72
+ const md = mv.duracion ?? Math.max(0.3, ctx.dur - m0);
73
+ const k = clamp((t - m0) / md);
74
+ const f = mv.curva === 'lineal' ? k : easeIO(k);
75
+ x = lerp(x, mv.a[0] ?? x, f);
76
+ y = lerp(y, mv.a[1] ?? y, f);
77
+ }
78
+ // escalar: {a: 1.5, en?, duracion?}
79
+ const es = c.escalar;
80
+ if (es?.a != null) {
81
+ const e0 = cuando(es.en, ctx, t0);
82
+ const k = easeIO(clamp((t - e0) / (es.duracion ?? 0.6)));
83
+ s *= lerp(1, es.a / (c.escala ?? 1), k);
84
+ }
85
+ if (c.girar) r += c.girar * (t - t0); // grados por segundo
86
+ if (c.flotar) {
87
+ const a = c.flotar === true ? 14 : c.flotar;
88
+ const fase = azar(ctx.semilla + (c.id ?? c.src ?? c.texto ?? '')) * 6.28;
89
+ y += Math.sin(t * 1.8 + fase) * a;
90
+ r += Math.sin(t * 1.3 + fase) * (a / 10);
91
+ }
92
+ if (c.latir) s *= 1 + 0.04 * Math.sin(t * 7);
93
+ // salida: hasta: palabra o segundos; sale con la animación `salida` (aparecer = desvanecer)
94
+ if (c.hasta != null) {
95
+ const h = cuando(c.hasta, ctx, ctx.dur);
96
+ const q = clamp((t - h) / 0.35);
97
+ if (q >= 1) return null;
98
+ if (q > 0) {
99
+ const sal = c.salida ?? 'aparecer';
100
+ if (sal === 'escala') s *= 1 - easeOut(q);
101
+ else if (sal === 'deslizar-izq') x -= easeOut(q) * W;
102
+ else if (sal === 'deslizar-der') x += easeOut(q) * W;
103
+ else if (sal === 'subir') y -= easeOut(q) * 300;
104
+ o *= 1 - q;
105
+ }
106
+ }
107
+ if (s <= 0.001 || o <= 0.001) return null;
108
+ return {x, y, s, r, o};
109
+ }
110
+
111
+ const sombra = (c: Capa) => (c.sombra === false ? undefined : c.sombra === true || c.sombra == null ? 'drop-shadow(0 18px 24px rgba(0,0,0,0.35))' : c.sombra);
112
+
113
+ /**
114
+ * Cambios en el tiempo (sobre todo para el HUD): cambios: [{en: 'palabra' | segundos | {escena: 3}, valor: 15}, ...].
115
+ * Los números (valor, a) se animan desde el valor anterior; lo demás cambia de golpe.
116
+ */
117
+ function aplicarCambios(c: Capa, ctx: CtxEscena): Capa {
118
+ if (!Array.isArray(c.cambios) || !c.cambios.length) return c;
119
+ const momento = (en: any) => (en && typeof en === 'object' && en.escena != null ? ctx.inicios?.[en.escena - 1] ?? 0 : cuando(en, ctx, 0));
120
+ const lista = c.cambios.map((k: any) => ({...k, __t: momento(k.en)})).sort((a: any, b: any) => a.__t - b.__t);
121
+ let actual: Capa = {...c};
122
+ let previo: Capa = c;
123
+ for (const k of lista) {
124
+ if (ctx.t < k.__t) break;
125
+ previo = actual;
126
+ const {en, __t, ...resto} = k;
127
+ actual = {...actual, ...resto, __cambio: __t - cuando(c.en, ctx, 0)};
128
+ if (typeof resto.valor === 'number') actual.__valorPrevio = previo.valor ?? 0;
129
+ if (c.tipo === 'contador' && typeof resto.a === 'number') actual = {...actual, de: previo.a ?? previo.de ?? 0, en: __t, duracion: resto.duracion ?? 0.8};
130
+ }
131
+ return actual;
132
+ }
133
+
134
+ /** Una capa con su animación aplicada. */
135
+ export const CapaVista: React.FC<{c: Capa; ctx: CtxEscena; relativo?: boolean; i?: number}> = ({c: c0, ctx, relativo = false, i = 0}) => {
136
+ const c = aplicarCambios(c0, ctx);
137
+ const a = animar(c, ctx, relativo);
138
+ if (!a) return null;
139
+ const ancla = c.ancla === 'izquierda' ? '0%' : c.ancla === 'derecha' ? '-100%' : '-50%';
140
+ return (
141
+ <div
142
+ style={{
143
+ position: 'absolute',
144
+ left: a.x,
145
+ top: a.y,
146
+ transform: `translate(${ancla}, -50%) scale(${a.s}) rotate(${a.r}deg)${c.voltear ? ' scaleX(-1)' : ''}`,
147
+ transformOrigin: c.ancla === 'izquierda' ? 'left center' : c.ancla === 'derecha' ? 'right center' : 'center',
148
+ opacity: a.o,
149
+ filter: [c.tipo === 'imagen' || c.tipo === 'emoji' ? sombra(c) : undefined, c.desenfoque ? `blur(${c.desenfoque}px)` : undefined].filter(Boolean).join(' ') || undefined,
150
+ zIndex: c.z ?? i,
151
+ }}
152
+ >
153
+ <Contenido c={c} ctx={ctx} />
154
+ </div>
155
+ );
156
+ };
157
+
158
+ const Contenido: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
159
+ const T = ctx.tema;
160
+ const fuente = (f?: string) => (f === 'mono' ? T.fuentes.mono : f === 'texto' ? T.fuentes.texto : f === 'titulo' || !f ? T.fuentes.titulo : f);
161
+ switch (c.tipo) {
162
+ case 'imagen':
163
+ if (c.cambiarA) return <ImagenCambia c={c} ctx={ctx} />;
164
+ return <Img src={recurso(c.src)} style={{width: c.ancho ?? 500, height: c.alto ?? 'auto', display: 'block', borderRadius: c.redondo ? '50%' : c.radio ?? 0, objectFit: 'cover'}} />;
165
+ case 'video':
166
+ return <OffthreadVideo src={recurso(c.src)} muted style={{width: c.ancho ?? 600, height: c.alto ?? 'auto', display: 'block', borderRadius: c.radio ?? 0}} />;
167
+ case 'texto':
168
+ return <TextoCapa c={c} ctx={ctx} fuente={fuente(c.fuente)} />;
169
+ case 'contador':
170
+ return <Contador c={c} ctx={ctx} />;
171
+ case 'chip':
172
+ return <Chip c={c} ctx={ctx} />;
173
+ case 'etiqueta':
174
+ return (
175
+ <div style={{fontFamily: T.fuentes.mono, fontSize: c.tam ?? 34, color: c.color ?? T.colores.suave, whiteSpace: 'pre', letterSpacing: 0.5}}>
176
+ {c.texto}
177
+ </div>
178
+ );
179
+ case 'numero': {
180
+ const tam = c.tam ?? 96;
181
+ return (
182
+ <div style={{width: tam, height: tam, background: c.fondo ?? T.colores.texto, color: c.color ?? (T.colores.texto === '#FFFFFF' ? '#000' : '#fff'), fontFamily: T.fuentes.titulo, fontSize: tam * 0.62, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: c.radio ?? 8}}>
183
+ {c.texto ?? c.valor}
184
+ </div>
185
+ );
186
+ }
187
+ case 'tarjeta':
188
+ return <Tarjeta c={c} ctx={ctx} />;
189
+ case 'telefono':
190
+ return <Telefono c={c} ctx={ctx} />;
191
+ case 'ventana':
192
+ return <Ventana c={c} ctx={ctx} />;
193
+ case 'forma':
194
+ return <Forma c={c} ctx={ctx} />;
195
+ case 'icono': {
196
+ const u = tamIcono(c.nombre, c.tam ?? 300);
197
+ const lado = (c.tam ?? 300) * 1.2;
198
+ return (
199
+ <div style={{position: 'relative', width: lado, height: lado}}>
200
+ <Icono nombre={c.nombre} cx={lado / 2} cy={lado / 2} u={u} color={c.color} />
201
+ </div>
202
+ );
203
+ }
204
+ case 'emoji':
205
+ return <div style={{fontSize: c.tam ?? 220, lineHeight: 1}}>{c.texto}</div>;
206
+ case 'marca': {
207
+ const tam = c.tam ?? 200;
208
+ return (
209
+ <div style={{position: 'relative', width: tam + 20, height: tam + 20}}>
210
+ <Avatar x={0} y={0} size={tam} aro={10} />
211
+ </div>
212
+ );
213
+ }
214
+ case 'particulas':
215
+ return (
216
+ <div style={{position: 'relative', width: W, height: H}}>
217
+ <Particulas p={{tipo: c.particulas ?? 'polvo', cantidad: c.cantidad, color: c.color}} t={ctx.t} semilla={ctx.semilla} />
218
+ </div>
219
+ );
220
+ case 'grupo':
221
+ return <Grupo c={c} ctx={ctx} />;
222
+ case 'escena3d':
223
+ return <Escena3D c={c} ctx={ctx} />;
224
+ case 'personaje':
225
+ return <Personaje c={c} ctx={ctx} />;
226
+ case 'barra':
227
+ return <Barra c={c} ctx={ctx} />;
228
+ case 'medidor':
229
+ return <Medidor c={c} ctx={ctx} />;
230
+ case 'votacion':
231
+ return <Votacion c={c} ctx={ctx} />;
232
+ case 'estado':
233
+ return <Estado c={c} ctx={ctx} />;
234
+ case 'noticia':
235
+ return <Noticia c={c} ctx={ctx} />;
236
+ case 'chat':
237
+ return <Chat c={c} ctx={ctx} />;
238
+ case 'onda':
239
+ return <Onda c={c} ctx={ctx} />;
240
+ case 'globo':
241
+ return <Globo c={c} ctx={ctx} />;
242
+ case 'seguir':
243
+ return <Seguir c={c} ctx={ctx} />;
244
+ case 'modelo':
245
+ return <Modelo3D c={c} ctx={ctx} />;
246
+ case 'componente': {
247
+ const Comp = componente(c.nombre);
248
+ if (!Comp) throw new Error(`No hay un componente llamado "${c.nombre}". Regístralo en morfeo.config.mjs → componentes. Disponibles: ${nombresComponentes().join(', ') || 'ninguno'}`);
249
+ const t0 = cuando(c.en, ctx, 0);
250
+ return <Comp {...(c.props ?? {})} t={ctx.t - t0} dur={ctx.dur} palabras={ctx.palabras} tema={ctx.tema} cuando={(en: unknown) => cuando(en, ctx) - t0} />;
251
+ }
252
+ default:
253
+ return <div style={{color: 'red', fontSize: 40, fontFamily: 'sans-serif', background: '#fff', padding: 10}}>capa desconocida: {c.tipo}</div>;
254
+ }
255
+ };
256
+
257
+ const TextoCapa: React.FC<{c: Capa; ctx: CtxEscena; fuente: string}> = ({c, ctx, fuente}) => {
258
+ const T = ctx.tema;
259
+ const may = c.mayusculas ?? (fuente === T.fuentes.titulo && T.tituloMayusculas);
260
+ let texto: string = String(c.texto ?? '');
261
+ if (may) texto = texto.toUpperCase();
262
+ // escribir: aparece letra por letra
263
+ if (c.escribir) {
264
+ const t0 = cuando(c.en, ctx, 0);
265
+ const vel = typeof c.escribir === 'number' ? c.escribir : 22; // letras por segundo
266
+ const n = Math.max(0, Math.floor((ctx.t - t0) * vel));
267
+ let cuenta = 0;
268
+ texto = texto.replace(/[^*]/g, (ch) => (cuenta++ < n ? ch : ch === '\n' ? '\n' : '')).replace(/\*\*/g, '');
269
+ }
270
+ const color = c.color ?? T.colores.texto;
271
+ const acento = c.acento ?? T.colores.acento;
272
+ const contorno = c.contorno ? `${c.contorno}px ${c.colorContorno ?? '#000'}` : undefined;
273
+ return (
274
+ <div
275
+ style={{
276
+ fontFamily: fuente,
277
+ fontSize: c.tam ?? 96,
278
+ fontWeight: c.peso ?? (fuente === T.fuentes.titulo ? 800 : 600),
279
+ lineHeight: c.interlineado ?? 1.05,
280
+ color,
281
+ textAlign: c.alinear ?? 'center',
282
+ maxWidth: c.maxAncho ?? 940,
283
+ whiteSpace: 'pre-wrap',
284
+ WebkitTextStroke: contorno,
285
+ paintOrder: 'stroke fill',
286
+ textShadow: c.sombra === false ? undefined : c.sombra ?? (color === '#FFFFFF' || color === '#fff' ? '0 6px 18px rgba(0,0,0,0.45)' : undefined),
287
+ background: c.fondo,
288
+ padding: c.fondo ? c.relleno ?? '10px 26px' : undefined,
289
+ borderRadius: c.fondo || c.borde ? c.radio ?? 14 : undefined,
290
+ border: c.borde ? `${c.grosor ?? 5}px solid ${c.borde}` : undefined,
291
+ width: c.ancho ?? (c.maxAncho === 'auto' ? undefined : 'max-content'),
292
+ boxSizing: 'border-box',
293
+ letterSpacing: c.espaciado,
294
+ }}
295
+ >
296
+ {partesResaltadas(texto).map(([s, r], i) =>
297
+ r ? (
298
+ <span key={i} style={{color: acento, ...(c.subrayado ? {textDecoration: 'underline', textDecorationThickness: 8, textUnderlineOffset: 10} : {})}}>
299
+ {s}
300
+ </span>
301
+ ) : (
302
+ <React.Fragment key={i}>{s}</React.Fragment>
303
+ ),
304
+ )}
305
+ </div>
306
+ );
307
+ };
308
+
309
+ const Contador: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
310
+ const T = ctx.tema;
311
+ const t0 = cuando(c.en, ctx, 0);
312
+ const dur = c.duracion ?? Math.max(0.6, ctx.habla[1] - t0);
313
+ const k = clamp((ctx.t - t0) / dur);
314
+ const de = c.de ?? 0;
315
+ const a = c.a ?? c.valor ?? 0;
316
+ let v: number;
317
+ if (c.curva === 'exponencial' && de > 0 && a > 0) v = de * Math.pow(a / de, k);
318
+ else v = lerp(de, a, c.curva === 'lineal' ? k : easeOut(k));
319
+ const txt = `${c.prefijo ?? ''}${formatoNumero(v, c.decimales ?? 0, c.miles ?? '.', c.decimal ?? ',')}`;
320
+ const sufijo = c.sufijo ?? '';
321
+ const formato = c.formato ?? 'grande';
322
+ if (formato === 'lcd')
323
+ return (
324
+ <div style={{background: c.fondo ?? '#0B0B0B', border: `4px solid ${T.colores.borde}`, borderRadius: 14, padding: '10px 28px', fontFamily: T.fuentes.mono, fontWeight: 800, fontSize: c.tam ?? 78, color: c.color ?? T.colores.acento, textShadow: `0 0 18px ${c.color ?? T.colores.acento}`, whiteSpace: 'pre'}}>
325
+ {txt}
326
+ {sufijo}
327
+ </div>
328
+ );
329
+ if (formato === 'chip') return <Chip c={{...c, texto: txt, unidad: sufijo.trim()}} ctx={ctx} />;
330
+ return (
331
+ <div style={{fontFamily: T.fuentes.titulo, fontWeight: 900, fontSize: c.tam ?? 200, color: c.color ?? T.colores.acento, lineHeight: 1, whiteSpace: 'pre', textShadow: '0 10px 30px rgba(0,0,0,0.35)'}}>
332
+ {txt}
333
+ <span style={{fontSize: (c.tam ?? 200) * 0.45}}>{sufijo}</span>
334
+ </div>
335
+ );
336
+ };
337
+
338
+ const Chip: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
339
+ const T = ctx.tema;
340
+ const tam = c.tam ?? 64;
341
+ return (
342
+ <div
343
+ style={{
344
+ display: 'flex',
345
+ alignItems: 'baseline',
346
+ gap: 10,
347
+ background: c.fondo ?? T.colores.tarjeta,
348
+ border: `3px solid ${c.borde ?? 'rgba(255,255,255,0.12)'}`,
349
+ borderRadius: 16,
350
+ padding: `${tam * 0.18}px ${tam * 0.4}px`,
351
+ fontFamily: T.fuentes.texto,
352
+ fontWeight: 800,
353
+ fontSize: tam,
354
+ color: c.color ?? T.colores.acento,
355
+ whiteSpace: 'pre',
356
+ boxShadow: '0 12px 30px rgba(0,0,0,0.35)',
357
+ }}
358
+ >
359
+ {c.texto}
360
+ {c.unidad ? <span style={{fontSize: tam * 0.5, color: T.colores.suave, fontWeight: 600}}>{c.unidad}</span> : null}
361
+ </div>
362
+ );
363
+ };
364
+
365
+ const Check: React.FC<{hecho: number; color: string; tam: number}> = ({hecho, color, tam}) => (
366
+ <div style={{width: tam, height: tam, borderRadius: tam * 0.25, border: `4px solid ${color}`, background: hecho > 0 ? color : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: tam * 0.7, fontWeight: 900, transform: `scale(${hecho > 0 ? backOut(hecho) : 1})`}}>
367
+ {hecho > 0 ? '✓' : ''}
368
+ </div>
369
+ );
370
+
371
+ const Tarjeta: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
372
+ const T = ctx.tema;
373
+ const ancho = c.ancho ?? 680;
374
+ const items: any[] = c.items ?? [];
375
+ const t0 = cuando(c.en, ctx, 0);
376
+ return (
377
+ <div style={{width: ancho, background: c.fondo ?? T.colores.tarjeta, color: T.colores.tarjetaTexto, borderRadius: 22, padding: '34px 40px', boxShadow: '0 22px 50px rgba(0,0,0,0.25)', border: c.borde ? `4px solid ${c.borde}` : undefined, fontFamily: T.fuentes.texto}}>
378
+ {c.cabecera ? <div style={{fontFamily: T.fuentes.mono, fontSize: 26, color: T.colores.suave, marginBottom: 10}}>{c.cabecera}</div> : null}
379
+ {c.titulo ? <div style={{fontSize: c.tamTitulo ?? 52, fontWeight: 800, marginBottom: items.length ? 22 : 0, lineHeight: 1.1}}>{partesResaltadas(c.titulo).map(([s, r], i) => (r ? <span key={i} style={{color: T.colores.acento}}>{s}</span> : s))}</div> : null}
380
+ {c.texto ? <div style={{fontSize: 38, lineHeight: 1.3, color: T.colores.tarjetaTexto}}>{c.texto}</div> : null}
381
+ {items.map((it, i) => {
382
+ const item = typeof it === 'string' ? {texto: it} : it;
383
+ // cada renglón aparece en su palabra (o escalonado) y se palomea en `check`
384
+ const ti = cuando(item.en, ctx, t0 + 0.25 + i * 0.18);
385
+ const vis = clamp((ctx.t - ti) / 0.3);
386
+ const tc = item.check === true ? ti + 0.3 : item.check != null && item.check !== false ? cuando(item.check, ctx, ti) : null;
387
+ const hecho = tc == null ? 0 : clamp((ctx.t - tc) / 0.3);
388
+ if (vis <= 0) return null;
389
+ return (
390
+ <div key={i} style={{display: 'flex', alignItems: 'center', gap: 20, fontSize: c.tamItems ?? 42, fontWeight: 700, padding: '10px 0', opacity: vis, transform: `translateX(${(1 - easeOut(vis)) * 40}px)`}}>
391
+ {c.checks !== false ? <Check hecho={hecho} color={item.color ?? T.colores.acento} tam={(c.tamItems ?? 42) * 1.05} /> : null}
392
+ <span>{item.texto}</span>
393
+ </div>
394
+ );
395
+ })}
396
+ </div>
397
+ );
398
+ };
399
+
400
+ const Telefono: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
401
+ const ancho = c.ancho ?? 470;
402
+ const alto = ancho * 2.05;
403
+ const borde = ancho * 0.035;
404
+ return (
405
+ <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'}}>
406
+ <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)`}}>
407
+ {/* sin imagen ni capas: una pantalla de app genérica en vez de un rectángulo negro */}
408
+ {!c.src && !c.capas?.length ? <PantallaGenerica ancho={ancho} t={ctx.t} /> : null}
409
+ {c.src ? (
410
+ /\.(mp4|webm|mov)$/i.test(c.src) ? (
411
+ <OffthreadVideo src={recurso(c.src)} muted style={{width: '100%', height: '100%', objectFit: 'cover'}} />
412
+ ) : (
413
+ <Img src={recurso(c.src)} style={{width: '100%', height: '100%', objectFit: 'cover'}} />
414
+ )
415
+ ) : null}
416
+ {/* las capas de la pantalla usan coordenadas relativas a su centro */}
417
+ <div style={{position: 'absolute', left: '50%', top: '50%', width: 0, height: 0}}>
418
+ {(c.capas ?? []).map((h: Capa, i: number) => (
419
+ <CapaVista key={i} c={h} ctx={ctx} relativo i={i} />
420
+ ))}
421
+ </div>
422
+ <div style={{position: 'absolute', top: borde, left: '50%', transform: 'translateX(-50%)', width: ancho * 0.28, height: ancho * 0.065, background: '#000', borderRadius: 99}} />
423
+ </div>
424
+ </div>
425
+ );
426
+ };
427
+
428
+ /**
429
+ * Ventana circular (escotilla, lupa, mirilla): muestra una imagen o capas adentro, con borde metálico y remaches.
430
+ * {tipo: 'ventana', diametro: 640, src: 'interior.png'} o {tipo: 'ventana', capas: [...]} (coordenadas desde el centro)
431
+ * borde: 'metal' | 'ninguno' | '#color' · remaches · zoom: [inicio, fin] para acercar el contenido · fondo: color detrás
432
+ */
433
+ const Ventana: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
434
+ const d = c.diametro ?? 640;
435
+ const aro = c.borde === 'ninguno' ? 0 : c.grosor ?? Math.round(d * 0.06);
436
+ const metal = c.borde && c.borde !== 'metal' && c.borde !== 'ninguno' ? c.borde : null;
437
+ const t0 = cuando(c.en, ctx, 0);
438
+ const k = clamp((ctx.t - t0) / Math.max(0.5, ctx.dur - t0));
439
+ const zoom = Array.isArray(c.zoom) ? lerp(c.zoom[0], c.zoom[1], easeIO(k)) : c.zoom ?? 1;
440
+ const n = c.remaches === false ? 0 : c.remaches ?? 16;
441
+ const total = d + aro * 2;
442
+ return (
443
+ <div style={{position: 'relative', width: total, height: total}}>
444
+ <div
445
+ style={{
446
+ position: 'absolute',
447
+ inset: 0,
448
+ borderRadius: '50%',
449
+ background: metal ?? 'conic-gradient(from 210deg, #8C96A3, #E3E8EE, #7A8592, #C9D0D8, #6E7884, #DDE3EA, #8C96A3)',
450
+ boxShadow: '0 24px 50px rgba(0,0,0,0.45), inset 0 0 0 3px rgba(0,0,0,0.35)',
451
+ }}
452
+ />
453
+ <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)'}}>
454
+ <div style={{position: 'absolute', inset: 0, transform: `scale(${zoom})`}}>
455
+ {c.src ? <Img src={recurso(c.src)} style={{width: '100%', height: '100%', objectFit: 'cover'}} /> : null}
456
+ <div style={{position: 'absolute', left: '50%', top: '50%', width: 0, height: 0}}>
457
+ {(c.capas ?? []).map((h: Capa, i: number) => (
458
+ <CapaVista key={i} c={h} ctx={ctx} relativo i={i} />
459
+ ))}
460
+ </div>
461
+ </div>
462
+ {/* brillo del vidrio */}
463
+ <div style={{position: 'absolute', inset: 0, borderRadius: '50%', background: 'radial-gradient(circle at 30% 25%, rgba(255,255,255,0.22), transparent 40%)'}} />
464
+ </div>
465
+ {Array.from({length: n}, (_, i) => {
466
+ const a = (i / n) * Math.PI * 2;
467
+ const r = d / 2 + aro / 2;
468
+ 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)'}} />;
469
+ })}
470
+ </div>
471
+ );
472
+ };
473
+
474
+ /** Pantalla de relleno: barra de app, un video con botón de play y renglones de texto. */
475
+ const PantallaGenerica: React.FC<{ancho: number; t: number}> = ({ancho, t}) => {
476
+ const u = ancho / 100;
477
+ const pulso = 1 + 0.05 * Math.sin(t * 4);
478
+ return (
479
+ <div style={{position: 'absolute', inset: 0, padding: `${12 * u}px ${7 * u}px`, display: 'flex', flexDirection: 'column', gap: 4 * u}}>
480
+ <div style={{display: 'flex', alignItems: 'center', gap: 3 * u}}>
481
+ <div style={{width: 9 * u, height: 9 * u, borderRadius: '50%', background: 'rgba(255,255,255,0.85)'}} />
482
+ <div style={{flex: 1, height: 3 * u, borderRadius: 99, background: 'rgba(255,255,255,0.6)'}} />
483
+ </div>
484
+ <div style={{position: 'relative', height: 60 * u, borderRadius: 5 * u, background: 'rgba(0,0,0,0.28)', display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
485
+ <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})`}}>
486
+ <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}} />
487
+ </div>
488
+ </div>
489
+ {[90, 70, 80, 50].map((w, i) => (
490
+ <div key={i} style={{width: `${w}%`, height: 4 * u, borderRadius: 99, background: 'rgba(255,255,255,0.55)'}} />
491
+ ))}
492
+ </div>
493
+ );
494
+ };
495
+
496
+ const Forma: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
497
+ const T = ctx.tema;
498
+ const color = c.color ?? T.colores.acento;
499
+ const w = c.ancho ?? 200;
500
+ const h = c.alto ?? (c.forma === 'linea' || c.forma === 'flecha' ? 10 : w);
501
+ if (c.forma === 'flecha') {
502
+ // largo animado: crece desde la izquierda al entrar
503
+ const t0 = cuando(c.en, ctx, 0);
504
+ const k = easeOut(clamp((ctx.t - t0) / 0.45));
505
+ return (
506
+ <svg width={w} height={60} style={{overflow: 'visible'}}>
507
+ <line x1={0} y1={30} x2={w * k - 20} y2={30} stroke={color} strokeWidth={c.grosor ?? 12} strokeLinecap="round" />
508
+ {k > 0.6 ? <polygon points={`${w * k},30 ${w * k - 46},6 ${w * k - 46},54`} fill={color} /> : null}
509
+ </svg>
510
+ );
511
+ }
512
+ return (
513
+ <div
514
+ style={{
515
+ width: w,
516
+ height: h,
517
+ background: c.relleno === false ? 'transparent' : color,
518
+ border: c.borde ? `${c.grosor ?? 6}px ${c.punteado ? 'dashed' : 'solid'} ${c.borde}` : undefined,
519
+ borderRadius: c.forma === 'circulo' ? '50%' : c.radio ?? (c.forma === 'linea' ? h / 2 : 0),
520
+ opacity: c.transparencia ?? 1,
521
+ boxShadow: c.brillo ? `0 0 ${c.brillo}px ${color}` : undefined,
522
+ }}
523
+ />
524
+ );
525
+ };
526
+
527
+ /**
528
+ * Grupo: varias capas que se mueven juntas (sus x, y son relativas al grupo), o una capa repetida:
529
+ * {tipo: 'grupo', repetir: 8, disposicion: 'circulo', radio: 320, capa: {tipo: 'imagen', src: 'ballena.png'}}
530
+ * disposiciones: circulo, fila, columna, cuadricula, pila, aleatorio. `retraso`: segundos entre cada copia.
531
+ */
532
+ const Grupo: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
533
+ let hijos: Capa[] = c.capas ?? [];
534
+ if (c.repetir && c.capa) {
535
+ const n = c.repetir;
536
+ const esp = c.espacio ?? 140;
537
+ const t0 = cuando(c.en, ctx, 0);
538
+ hijos = Array.from({length: n}, (_, i) => {
539
+ let x = 0;
540
+ let y = 0;
541
+ let rot = 0;
542
+ switch (c.disposicion ?? 'fila') {
543
+ case 'circulo': {
544
+ const ang = (i / n) * Math.PI * 2 + ((c.giroInicial ?? 0) * Math.PI) / 180;
545
+ x = Math.cos(ang) * (c.radio ?? 300);
546
+ y = Math.sin(ang) * (c.radio ?? 300);
547
+ if (c.orientar) rot = (ang * 180) / Math.PI + (c.orientar === true ? 0 : c.orientar);
548
+ break;
549
+ }
550
+ case 'fila':
551
+ x = (i - (n - 1) / 2) * esp;
552
+ break;
553
+ case 'columna':
554
+ y = (i - (n - 1) / 2) * esp;
555
+ break;
556
+ case 'cuadricula': {
557
+ const cols = c.columnas ?? Math.ceil(Math.sqrt(n));
558
+ const filas = Math.ceil(n / cols);
559
+ x = ((i % cols) - (cols - 1) / 2) * esp;
560
+ y = (Math.floor(i / cols) - (filas - 1) / 2) * (c.espacioV ?? esp);
561
+ break;
562
+ }
563
+ case 'pila':
564
+ y = -i * (c.espacio ?? 14);
565
+ x = (azar(`${ctx.semilla}p${i}`) - 0.5) * (c.desorden ?? 6);
566
+ break;
567
+ case 'aleatorio': {
568
+ const [aw, ah] = c.area ?? [900, 1400];
569
+ x = (azar(`${ctx.semilla}x${i}`) - 0.5) * aw;
570
+ y = (azar(`${ctx.semilla}y${i}`) - 0.5) * ah;
571
+ rot = c.girarAzar ? (azar(`${ctx.semilla}r${i}`) - 0.5) * c.girarAzar : 0;
572
+ break;
573
+ }
574
+ }
575
+ return {...c.capa, id: `${c.capa.id ?? 'r'}${i}`, x: x + (c.capa.x ?? 0), y: y + (c.capa.y ?? 0), rotar: (c.capa.rotar ?? 0) + rot, en: c.capa.en ?? t0 + i * (c.retraso ?? 0.06)};
576
+ });
577
+ }
578
+ return (
579
+ <div style={{position: 'relative', width: 0, height: 0}}>
580
+ {hijos.map((h, i) => (
581
+ <CapaVista key={i} c={h} ctx={ctx} relativo i={i} />
582
+ ))}
583
+ </div>
584
+ );
585
+ };
586
+
587
+ /**
588
+ * Personaje con poses: pose inicial y cambios en palabras ({en: 'enamorado', pose: 'feliz'}).
589
+ * Al cambiar de pose rebota un poco; con `hablar` se mueve mientras suena la voz.
590
+ */
591
+ const Personaje: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
592
+ const poses: Record<string, string> = c.__poses ?? {};
593
+ let pose = c.pose ?? Object.keys(poses)[0];
594
+ let desde = cuando(c.en, ctx, 0);
595
+ for (const k of [...(c.poses ?? [])].map((k: any) => ({...k, t: cuando(k.en, ctx, 0)})).sort((a: any, b: any) => a.t - b.t)) {
596
+ if (ctx.t >= k.t) {
597
+ pose = k.pose;
598
+ desde = k.t;
599
+ }
600
+ }
601
+ const src = poses[pose];
602
+ if (!src) throw new Error(`El personaje "${c.nombre}" no tiene la pose "${pose}". Tiene: ${Object.keys(poses).join(', ')}`);
603
+ const rebote = desde > cuando(c.en, ctx, 0) ? 1 + 0.08 * (1 - easeOut(clamp((ctx.t - desde) / 0.35))) : 1;
604
+ const habla = c.hablar && ctx.t >= ctx.habla[0] && ctx.t <= ctx.habla[1] ? Math.sin(ctx.t * 17) * 0.018 : 0;
605
+ return (
606
+ <Img
607
+ src={recurso(src)}
608
+ style={{width: c.ancho ?? 560, display: 'block', transform: `scale(${rebote}, ${rebote * (1 + habla)})`, transformOrigin: 'bottom center', filter: 'drop-shadow(0 14px 18px rgba(0,0,0,0.25))'}}
609
+ />
610
+ );
611
+ };
612
+
613
+ /** Todas las capas de una escena. */
614
+ export const Capas: React.FC<{capas: Capa[]; ctx: CtxEscena}> = ({capas, ctx}) => (
615
+ <>
616
+ {capas.map((c, i) => (
617
+ <CapaVista key={i} c={c} ctx={ctx} i={i} />
618
+ ))}
619
+ </>
620
+ );
621
+
622
+ export {useMarca};