morfeo 0.1.3 → 0.2.0

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.
@@ -3,7 +3,8 @@
3
3
  import React from 'react';
4
4
  import {Img, OffthreadVideo} from 'remotion';
5
5
  import {Avatar, Icono, tamIcono, useMarca} from '../piezas';
6
- import {H, W, backOut, clamp, easeIO, easeOut, lerp} from '../estilo';
6
+ import {H, W, backOut, clamp, easeIO, easeOut, easeOutExpo, lerp, resorte} from '../estilo';
7
+ import {ANOTACIONES, ANOTACIONES_SIN_ENTRADA, Anotacion} from './anotaciones';
7
8
  import {azar, cuando, formatoNumero, partesResaltadas, recurso, type CtxEscena} from './util';
8
9
  import {Particulas} from './fondo';
9
10
  import {Escena3D, Modelo3D} from './modelo3d';
@@ -16,13 +17,18 @@ export type Capa = Record<string, any> & {tipo: string};
16
17
  const ENTRADA_DUR = 0.5;
17
18
 
18
19
  // 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
+ // las anotaciones que se dibujan solas (línea que avanza) tampoco llevan entrada de capa
21
+ const entradaPorDefecto = (c: Capa) => (c.tipo === 'personaje' || ANOTACIONES_SIN_ENTRADA.includes(c.tipo) ? 'ninguna' : c.tipo === 'fuente' || c.tipo === 'seccion' ? 'subir' : 'escala');
20
22
 
21
23
  /** Posición, escala, giro y opacidad de una capa en el instante t (entrada, movimiento, salida). */
22
- function animar(c: Capa, ctx: CtxEscena, relativo: boolean) {
24
+ export function animar(c: Capa, ctx: CtxEscena, relativo: boolean) {
23
25
  const t = ctx.t;
24
26
  const t0 = cuando(c.en, ctx, 0);
25
- const dur = c.duracionEntrada ?? ENTRADA_DUR;
27
+ const mov = ctx.tema?.movimiento ?? {};
28
+ const dur = c.duracionEntrada ?? mov.entrada ?? ENTRADA_DUR;
29
+ const curva = c.curva ?? mov.curva ?? 'suave';
30
+ const E = curva === 'resorte' ? (x: number) => resorte(x, 0.3) : curva === 'seca' ? easeOutExpo : easeOut;
31
+ let b = 0; // desenfoque de entrada (px)
26
32
  const p = dur > 0 ? clamp((t - t0) / dur) : t >= t0 ? 1 : 0;
27
33
  let x = c.x ?? (relativo ? 0 : W / 2);
28
34
  let y = c.y ?? (relativo ? 0 : H / 2);
@@ -32,38 +38,43 @@ function animar(c: Capa, ctx: CtxEscena, relativo: boolean) {
32
38
  if (t < t0) return null; // con entrada 'ninguna' aparece de golpe en su momento
33
39
  switch (c.entrada ?? entradaPorDefecto(c)) {
34
40
  case 'aparecer':
35
- o *= easeOut(p);
41
+ o *= E(p);
36
42
  break;
37
43
  case 'escala':
38
- s *= easeOut(p);
44
+ s *= E(p);
39
45
  o *= clamp(p * 3);
40
46
  break;
41
47
  case 'rebote':
42
48
  s *= backOut(p);
43
49
  break;
44
50
  case 'deslizar-izq':
45
- x -= (1 - easeOut(p)) * W;
51
+ x -= (1 - E(p)) * W;
46
52
  break;
47
53
  case 'deslizar-der':
48
- x += (1 - easeOut(p)) * W;
54
+ x += (1 - E(p)) * W;
49
55
  break;
50
56
  case 'deslizar-arriba':
51
- y += (1 - easeOut(p)) * H * 0.6;
57
+ y += (1 - E(p)) * H * 0.6;
52
58
  break;
53
59
  case 'deslizar-abajo':
54
- y -= (1 - easeOut(p)) * H * 0.6;
60
+ y -= (1 - E(p)) * H * 0.6;
55
61
  break;
56
62
  case 'caer':
57
63
  y -= (1 - backOut(p, 1.2)) * 700;
58
64
  o *= clamp(p * 4);
59
65
  break;
60
66
  case 'girar':
61
- r += (1 - easeOut(p)) * -200;
62
- s *= easeOut(p);
67
+ r += (1 - E(p)) * -200;
68
+ s *= E(p);
69
+ break;
70
+ case 'enfocar':
71
+ o *= E(p);
72
+ b = (1 - E(p)) * 22;
73
+ s *= lerp(1.08, 1, E(p));
63
74
  break;
64
75
  case 'subir':
65
- y += (1 - easeOut(p)) * 80;
66
- o *= easeOut(p);
76
+ y += (1 - E(p)) * 80;
77
+ o *= E(p);
67
78
  break;
68
79
  }
69
80
  // movimiento: mover: {a: [x, y], en?, duracion?, curva?: 'suave' | 'lineal'}
@@ -94,7 +105,7 @@ function animar(c: Capa, ctx: CtxEscena, relativo: boolean) {
94
105
  // salida: hasta: palabra o segundos; sale con la animación `salida` (aparecer = desvanecer)
95
106
  if (c.hasta != null) {
96
107
  const h = cuando(c.hasta, ctx, ctx.dur);
97
- const q = clamp((t - h) / 0.35);
108
+ const q = clamp((t - h) / (mov.salida ?? 0.35));
98
109
  if (q >= 1) return null;
99
110
  if (q > 0) {
100
111
  const sal = c.salida ?? 'aparecer';
@@ -106,7 +117,7 @@ function animar(c: Capa, ctx: CtxEscena, relativo: boolean) {
106
117
  }
107
118
  }
108
119
  if (s <= 0.001 || o <= 0.001) return null;
109
- return {x, y, s, r, o};
120
+ return {x, y, s, r, o, b};
110
121
  }
111
122
 
112
123
  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);
@@ -133,10 +144,16 @@ function aplicarCambios(c: Capa, ctx: CtxEscena): Capa {
133
144
  }
134
145
 
135
146
  /** Una capa con su animación aplicada. */
136
- export const CapaVista: React.FC<{c: Capa; ctx: CtxEscena; relativo?: boolean; i?: number}> = ({c: c0, ctx, relativo = false, i = 0}) => {
147
+ export const CapaVista: React.FC<{c: Capa; ctx: CtxEscena; relativo?: boolean; i?: number; base?: {x: number; y: number} | null}> = ({c: c0, ctx, relativo = false, i = 0, base}) => {
137
148
  const c = aplicarCambios(c0, ctx);
138
- const a = animar(c, ctx, relativo);
149
+ // `sigue: 'id'`: la capa se coloca respecto a otra (x, y son la distancia a su centro) y se mueve con ella
150
+ if (c.sigue && !base) return null;
151
+ const a = animar(c, ctx, relativo || !!base);
139
152
  if (!a) return null;
153
+ if (base) {
154
+ a.x += base.x;
155
+ a.y += base.y;
156
+ }
140
157
  const ancla = c.ancla === 'izquierda' ? '0%' : c.ancla === 'derecha' ? '-100%' : '-50%';
141
158
  return (
142
159
  <div
@@ -147,7 +164,7 @@ export const CapaVista: React.FC<{c: Capa; ctx: CtxEscena; relativo?: boolean; i
147
164
  transform: `translate(${ancla}, -50%) scale(${a.s}) rotate(${a.r}deg)${c.voltear ? ' scaleX(-1)' : ''}`,
148
165
  transformOrigin: c.ancla === 'izquierda' ? 'left center' : c.ancla === 'derecha' ? 'right center' : 'center',
149
166
  opacity: a.o,
150
- filter: [c.tipo === 'imagen' || c.tipo === 'emoji' ? sombra(c) : undefined, c.desenfoque ? `blur(${c.desenfoque}px)` : undefined].filter(Boolean).join(' ') || undefined,
167
+ filter: [c.tipo === 'imagen' || c.tipo === 'emoji' ? sombra(c) : undefined, c.desenfoque || a.b ? `blur(${(c.desenfoque ?? 0) + a.b}px)` : undefined].filter(Boolean).join(' ') || undefined,
151
168
  zIndex: c.z ?? i,
152
169
  }}
153
170
  >
@@ -257,9 +274,10 @@ const Contenido: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
257
274
  const Comp = componente(c.nombre);
258
275
  if (!Comp) throw new Error(`No hay un componente llamado "${c.nombre}". Regístralo en morfeo.config.mjs → componentes. Disponibles: ${nombresComponentes().join(', ') || 'ninguno'}`);
259
276
  const t0 = cuando(c.en, ctx, 0);
260
- return <Comp {...(c.props ?? {})} t={ctx.t - t0} dur={ctx.dur} palabras={ctx.palabras} tema={ctx.tema} cuando={(en: unknown) => cuando(en, ctx) - t0} />;
277
+ return <Comp {...(c.props ?? {})} t={ctx.t - t0} dur={ctx.dur} palabras={ctx.palabras} tema={ctx.tema} cuando={(en: unknown) => cuando(en, ctx) - t0} escena={ctx.escena} tEscena={ctx.tEscena} inicios={ctx.inicios} camara={ctx.camara} />;
261
278
  }
262
279
  default:
280
+ if (ANOTACIONES.includes(c.tipo)) return <Anotacion c={c} ctx={ctx} />;
263
281
  return <div style={{color: 'red', fontSize: 40, fontFamily: 'sans-serif', background: '#fff', padding: 10}}>capa desconocida: {c.tipo}</div>;
264
282
  }
265
283
  };
@@ -621,12 +639,17 @@ const Personaje: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
621
639
  };
622
640
 
623
641
  /** Todas las capas de una escena. */
624
- export const Capas: React.FC<{capas: Capa[]; ctx: CtxEscena}> = ({capas, ctx}) => (
625
- <>
626
- {capas.map((c, i) => (
627
- <CapaVista key={i} c={c} ctx={ctx} i={i} />
628
- ))}
629
- </>
630
- );
642
+ export const Capas: React.FC<{capas: Capa[]; ctx: CtxEscena}> = ({capas, ctx}) => {
643
+ // posiciones de las capas con `id`, para las que las siguen (`sigue: 'id'`)
644
+ const pos: Record<string, {x: number; y: number} | null> = {};
645
+ if (capas.some((c) => c.sigue)) for (const c of capas) if (c.id != null && !c.sigue) pos[c.id] = animar(aplicarCambios(c, ctx), ctx, false);
646
+ return (
647
+ <>
648
+ {capas.map((c, i) => (
649
+ <CapaVista key={i} c={c} ctx={ctx} i={i} base={c.sigue ? pos[c.sigue] ?? null : undefined} />
650
+ ))}
651
+ </>
652
+ );
653
+ };
631
654
 
632
655
  export {useMarca};
@@ -156,5 +156,59 @@ export const FondoTema: React.FC<{tema: Tema; fondo?: TFondo; t: number; semilla
156
156
  );
157
157
  };
158
158
 
159
- export const Vineta: React.FC<{fuerza: number}> = ({fuerza}) =>
160
- fuerza > 0 ? <AbsoluteFill style={{background: `radial-gradient(ellipse at center, transparent 45%, rgba(0,0,0,${fuerza}) 100%)`, pointerEvents: 'none'}} /> : null;
159
+ /** Convierte #RRGGBB a "r, g, b". */
160
+ const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(', ');
161
+
162
+ export const Vineta: React.FC<{fuerza: number; color?: string}> = ({fuerza, color}) =>
163
+ fuerza > 0 ? <AbsoluteFill style={{background: `radial-gradient(ellipse at center, transparent 45%, rgba(${color ? rgb(color) : '0, 0, 0'}, ${fuerza}) 100%)`, pointerEvents: 'none'}} /> : null;
164
+
165
+ /**
166
+ * Acabado sobre todo el cuadro (va quieto, fuera de la cámara):
167
+ * luz: cono de luz desde arriba (lámpara, foco de escenario) · tinte: capa de color que unifica la paleta ·
168
+ * desenfoqueBordes: blur arriba y abajo, que da sensación de maqueta y de profundidad.
169
+ */
170
+ export const Post: React.FC<{tema: Tema}> = ({tema}) => {
171
+ const p = tema.post;
172
+ if (!p) return null;
173
+ const L = p.luz;
174
+ const lx = L?.x ?? 540;
175
+ const ly = L?.y ?? -60;
176
+ const ancho = L?.ancho ?? 760;
177
+ const largo = L?.largo ?? 1500;
178
+ const borde = (mascara: string): React.CSSProperties => ({
179
+ backdropFilter: `blur(${p.desenfoqueBordes}px)`,
180
+ WebkitBackdropFilter: `blur(${p.desenfoqueBordes}px)`,
181
+ maskImage: mascara,
182
+ WebkitMaskImage: mascara,
183
+ pointerEvents: 'none',
184
+ });
185
+ return (
186
+ <>
187
+ {L ? (
188
+ <AbsoluteFill style={{mixBlendMode: 'screen', opacity: L.fuerza ?? 0.5, pointerEvents: 'none'}}>
189
+ <svg width={W} height={H}>
190
+ <defs>
191
+ <linearGradient id="post-luz" x1="0" y1="0" x2="0" y2="1">
192
+ <stop offset="0" stopColor={L.color ?? '#FFE6B0'} stopOpacity="0.95" />
193
+ <stop offset="1" stopColor={L.color ?? '#FFE6B0'} stopOpacity="0" />
194
+ </linearGradient>
195
+ <filter id="post-luz-blur" x="-30%" y="-10%" width="160%" height="120%">
196
+ <feGaussianBlur stdDeviation="38" />
197
+ </filter>
198
+ </defs>
199
+ <g transform={`rotate(${L.angulo ?? 0} ${lx} ${ly})`} filter="url(#post-luz-blur)">
200
+ <polygon points={`${lx - 40},${ly} ${lx + 40},${ly} ${lx + ancho / 2},${ly + largo} ${lx - ancho / 2},${ly + largo}`} fill="url(#post-luz)" />
201
+ </g>
202
+ </svg>
203
+ </AbsoluteFill>
204
+ ) : null}
205
+ {p.desenfoqueBordes ? (
206
+ <>
207
+ <AbsoluteFill style={borde('linear-gradient(180deg, #000 0%, transparent 24%)')} />
208
+ <AbsoluteFill style={borde('linear-gradient(0deg, #000 0%, transparent 22%)')} />
209
+ </>
210
+ ) : null}
211
+ {p.tinte ? <AbsoluteFill style={{background: p.tinte.color, opacity: p.tinte.fuerza ?? 0.25, mixBlendMode: p.tinte.modo ?? 'soft-light', pointerEvents: 'none'}} /> : null}
212
+ </>
213
+ );
214
+ };
@@ -24,7 +24,7 @@ export function capasDe(tipo: string, p: P, tema: Tema, marca: Marca): Capa[] {
24
24
  const out: Capa[] = [];
25
25
  if (p.etiqueta) out.push({tipo: 'etiqueta', texto: p.etiqueta, x: 80, y: y - 92, ancla: 'izquierda', entrada: 'aparecer'});
26
26
  out.push({tipo: 'texto', texto: p.texto ?? '', x: 80, y, ancla: 'izquierda', alinear: 'left', tam: p.tam ?? 104, maxAncho: p.numero != null ? 800 : 920, entrada: 'subir', en: p.en});
27
- if (p.numero != null) out.push({tipo: 'numero', texto: String(p.numero), x: 1000, y, ancla: 'derecha', entrada: 'rebote', en: 0.15});
27
+ if (p.numero != null) out.push({tipo: 'insignia', texto: String(p.numero), x: 1000, y, ancla: 'derecha', entrada: 'rebote', en: 0.15});
28
28
  if (p.sub) out.push({tipo: 'texto', texto: p.sub, fuente: 'texto', tam: 44, peso: 600, color: tema.colores.suave, x: 80, y: y + 110, ancla: 'izquierda', alinear: 'left', entrada: 'aparecer', en: 0.3});
29
29
  return [...out, ...extra];
30
30
  }
@@ -114,6 +114,31 @@ export function capasDe(tipo: string, p: P, tema: Tema, marca: Marca): Capa[] {
114
114
  }
115
115
  case 'cta': {
116
116
  const nombre = marca.nombre;
117
+ // cierre que pide una acción concreta (lo que hacen los videos virales): comentar una palabra o mandárselo a alguien
118
+ if (p.pide === 'comenta' || p.pide === 'comparte') {
119
+ const claro = tema.colores.texto !== '#FFFFFF';
120
+ const caja = claro ? '#FFFFFF' : '#1C1D24';
121
+ const letra = claro ? '#141414' : '#FFFFFF';
122
+ const out: Capa[] = [{tipo: 'marca', tam: 210, x: 540, y: 640, entrada: 'rebote'}];
123
+ if (p.pide === 'comenta') {
124
+ const palabra = String(p.palabra ?? 'SÍ').toUpperCase();
125
+ out.push(
126
+ {tipo: 'texto', texto: p.texto ?? `Escribe *${palabra}*`, x: 540, y: 900, tam: 110, maxAncho: 940, entrada: 'subir', en: 0.2},
127
+ ...(p.sub ? [{tipo: 'texto', texto: p.sub, fuente: 'texto', tam: 44, peso: 700, color: tema.colores.suave, x: 540, y: 1010, entrada: 'aparecer', en: 0.4, mayusculas: false}] : []),
128
+ // caja de comentario que se escribe sola y se envía
129
+ {tipo: 'terminal', x: 540, y: 1220, ancho: 760, tam: 46, titulo: 'comentario', fondo: caja, colorTexto: letra, en: 0.5, entrada: 'subir', color: tema.colores.acento, lineas: [{texto: palabra, en: 0.9}]},
130
+ {tipo: 'sello', texto: 'ENVIADO', x: 760, y: 1360, en: 2.1, color: tema.colores.acento, tam: 44},
131
+ );
132
+ } else {
133
+ out.push(
134
+ {tipo: 'texto', texto: p.texto ?? 'Mándaselo a *alguien*', x: 540, y: 900, tam: 104, maxAncho: 940, entrada: 'subir', en: 0.2},
135
+ ...(p.sub ? [{tipo: 'texto', texto: p.sub, fuente: 'texto', tam: 44, peso: 700, color: tema.colores.suave, x: 540, y: 1010, maxAncho: 900, entrada: 'aparecer', en: 0.4, mayusculas: false}] : []),
136
+ {tipo: 'texto', texto: `ENVIAR A…\n${p.a ?? 'AMIGO'}`, fuente: 'texto', tam: 46, peso: 800, color: letra, fondo: caja, relleno: '24px 60px', radio: 26, interlineado: 1.5, x: 540, y: 1230, entrada: 'subir', en: 0.5, mayusculas: false},
137
+ {tipo: 'texto', texto: 'ENVIADO ✓', fuente: 'texto', tam: 50, peso: 900, color: '#141414', fondo: tema.colores.acento, relleno: '16px 54px', radio: 99, x: 540, y: 1390, entrada: 'rebote', en: 1.6, sombra: false, sonido: 'ding'},
138
+ );
139
+ }
140
+ return [...out, ...extra];
141
+ }
117
142
  return [
118
143
  {tipo: 'marca', tam: 330, x: 540, y: 760, entrada: 'rebote', latir: true},
119
144
  ...(nombre ? [{tipo: 'texto', texto: nombre, x: 540, y: 1030, tam: 120, color: tema.colores.texto, entrada: 'rebote', en: 0.25, mayusculas: false}] : []),
@@ -1,15 +1,26 @@
1
1
  // Subtítulos del motor de capas.
2
- // karaoke: frase corta arriba; lo ya dicho en blanco, lo que falta tenue y la palabra actual resaltada.
3
- // pop: 1–3 palabras grandes en mayúsculas, la actual en color de acento (estilo TikTok clásico).
2
+ // karaoke: frase corta arriba; lo ya dicho en un color, lo que falta tenue y la palabra actual resaltada.
3
+ // seleccion: las palabras aparecen al decirse; lo dicho queda tenue, la actual en claro y la palabra clave de la
4
+ // frase se queda dentro de una caja de selección con asas (documentales ilustrados).
5
+ // pop: 1–3 palabras grandes en mayúsculas; la palabra clave en color de acento y la actual da un golpe de escala.
4
6
  // bloque: 2 renglones centrados en mayúsculas; la palabra actual dentro de una caja de color (series animadas).
5
- // palabra: una sola palabra grande a la vez; las largas en color de acento (videos 3D).
7
+ // palabra: una sola palabra grande a la vez; la palabra clave en color de acento (videos 3D).
8
+ // caja: la frase completa dentro de una etiqueta clara (historias, anime).
6
9
  // karaoke con `fondo`: la frase dentro de una caja oscura (infografías).
10
+ // Palabra clave de una frase: la que la escena marque en `claves: ['cerebro']`; si no, una cifra; si no, la más larga.
7
11
  import React from 'react';
8
- import {W, backOut, clamp} from '../estilo';
12
+ import {W, backOut, clamp, easeOut, resorte} from '../estilo';
9
13
  import type {Tema} from './tema';
10
14
 
11
15
  type Palabra = [string, number, number];
12
16
 
17
+ const limpia = (w: string) =>
18
+ w
19
+ .normalize('NFD')
20
+ .replace(/[̀-ͯ]/g, '')
21
+ .toLowerCase()
22
+ .replace(/[^a-z0-9]/g, '');
23
+
13
24
  /** Parte las palabras de la escena en frases de hasta `max` palabras, cortando en la puntuación. */
14
25
  function frases(palabras: Palabra[], max: number): Palabra[][] {
15
26
  const out: Palabra[][] = [];
@@ -25,23 +36,54 @@ function frases(palabras: Palabra[], max: number): Palabra[][] {
25
36
  return out;
26
37
  }
27
38
 
28
- export const Subtitulos: React.FC<{tema: Tema; palabras: Palabra[]; t: number; dur: number}> = ({tema, palabras, t, dur}) => {
39
+ /** Índice de la palabra clave de la frase (-1 = ninguna). */
40
+ function claveDe(g: Palabra[], claves?: string[]): number {
41
+ if (claves?.length) {
42
+ const cs = claves.map(limpia).filter(Boolean);
43
+ const i = g.findIndex(([w]) => cs.some((c) => limpia(w).startsWith(c)));
44
+ if (i >= 0) return i;
45
+ }
46
+ const cifra = g.findIndex(([w]) => /\d/.test(w));
47
+ if (cifra >= 0) return cifra;
48
+ let mejor = -1;
49
+ let largo = 7; // solo palabras de 8 letras o más
50
+ g.forEach(([w], i) => {
51
+ const n = limpia(w).length;
52
+ if (n > largo) {
53
+ largo = n;
54
+ mejor = i;
55
+ }
56
+ });
57
+ return mejor;
58
+ }
59
+
60
+ /** Golpe de escala al decir una palabra: sube de golpe y se asienta. */
61
+ const golpe = (t: number, a: number, fuerza: number) => (t < a ? 1 : 1 + fuerza * (1 - easeOut((t - a) / 0.22)));
62
+
63
+ const Asa: React.FC<{x: string; y: string; color: string}> = ({x, y, color}) => (
64
+ <span style={{position: 'absolute', left: x, top: y, width: 11, height: 11, marginLeft: -6, marginTop: -6, background: '#fff', border: `2.5px solid ${color}`, boxSizing: 'border-box'}} />
65
+ );
66
+
67
+ export const Subtitulos: React.FC<{tema: Tema; palabras: Palabra[]; t: number; dur: number; claves?: string[]}> = ({tema, palabras, t, dur, claves}) => {
29
68
  const S = tema.subtitulos;
30
69
  if (S.tipo === 'ninguno' || !palabras.length) return null;
31
- const max = S.tipo === 'pop' ? 3 : S.tipo === 'bloque' ? Math.min(S.maxPalabras, 5) : S.tipo === 'palabra' ? 1 : S.maxPalabras;
70
+ const max = S.tipo === 'pop' ? Math.min(S.maxPalabras, 4) : S.tipo === 'bloque' ? Math.min(S.maxPalabras, 5) : S.tipo === 'palabra' ? 1 : S.maxPalabras;
32
71
  const grupos = frases(palabras, max);
33
72
  const gi = grupos.findIndex((g, i) => t < (grupos[i + 1]?.[0][1] ?? dur) - 0.02);
34
73
  const g = grupos[gi];
35
74
  if (!g || t < g[0][1] - 0.12) return null;
36
75
  const entrada = clamp((t - (g[0][1] - 0.12)) / 0.18);
76
+ const clave = claveDe(g, claves);
77
+ const texto = (w: string) => (S.mayusculas ? w.toUpperCase() : w);
37
78
 
38
79
  if (S.tipo === 'palabra') {
39
80
  const [w] = g[0];
40
- const acento = w.replace(/[^\p{L}\p{N}]/gu, '').length >= 7;
81
+ // con una palabra por frase, la clave se decide sobre toda la escena: las marcadas, las cifras y las largas
82
+ const acento = claveDe([g[0]], claves) === 0;
41
83
  return (
42
- <div style={{position: 'absolute', left: 40, width: W - 80, top: S.y, transform: `translateY(-50%) scale(${backOut(entrada)})`, textAlign: 'center'}}>
84
+ <div style={{position: 'absolute', left: 40, width: W - 80, top: S.y, transform: `translateY(-50%) scale(${resorte(entrada, 0.25)})`, textAlign: 'center'}}>
43
85
  <span style={{fontFamily: tema.fuentes.texto, fontWeight: 900, fontSize: S.tam * 1.25, color: acento ? tema.colores.acento : S.color, WebkitTextStroke: `${S.contorno ?? 9}px #000`, paintOrder: 'stroke fill', textShadow: '0 6px 0 rgba(0,0,0,0.35)'}}>
44
- {S.mayusculas ? w.toUpperCase() : w}
86
+ {texto(w)}
45
87
  </span>
46
88
  </div>
47
89
  );
@@ -67,7 +109,8 @@ export const Subtitulos: React.FC<{tema: Tema; palabras: Palabra[]; t: number; d
67
109
  borderRadius: 10,
68
110
  WebkitTextStroke: actual && S.resalte ? undefined : `${S.contorno ?? 8}px #1a1a1a`,
69
111
  paintOrder: 'stroke fill',
70
- transform: `scale(${actual ? 1.06 : 1})`,
112
+ display: 'inline-block',
113
+ transform: `scale(${golpe(t, a, 0.1)})`,
71
114
  }}
72
115
  >
73
116
  {w}
@@ -79,14 +122,94 @@ export const Subtitulos: React.FC<{tema: Tema; palabras: Palabra[]; t: number; d
79
122
  }
80
123
 
81
124
  if (S.tipo === 'pop') {
125
+ const tam = S.tam * 1.5;
82
126
  return (
83
- <div style={{position: 'absolute', left: 60, width: W - 120, top: S.y, transform: `translateY(-50%) scale(${backOut(entrada)})`, display: 'flex', justifyContent: 'center', flexWrap: 'wrap', gap: `0 ${Math.round(S.tam * 1.5 * 0.4 + 20)}px`}}>
84
- {g.map(([w, a, b], i) => {
85
- const activa = t >= a && t < b + 0.05;
127
+ <div style={{position: 'absolute', left: 60, width: W - 120, top: S.y, transform: `translateY(-50%) scale(${resorte(entrada, 0.2)})`, display: 'flex', justifyContent: 'center', flexWrap: 'wrap', gap: `0 ${Math.round(tam * 0.3)}px`}}>
128
+ {g.map(([w, a], i) => (
129
+ <span
130
+ key={i}
131
+ style={{
132
+ fontFamily: tema.fuentes.titulo,
133
+ fontWeight: 900,
134
+ fontSize: tam,
135
+ lineHeight: 1.12,
136
+ color: i === clave ? tema.colores.acento : S.color,
137
+ WebkitTextStroke: `${S.contorno ?? 10}px #000`,
138
+ paintOrder: 'stroke fill',
139
+ textShadow: '0 6px 0 rgba(0,0,0,0.4)',
140
+ textTransform: 'uppercase',
141
+ display: 'inline-block',
142
+ transformOrigin: 'center bottom',
143
+ opacity: t >= a - 0.04 ? 1 : 0.55,
144
+ transform: `scale(${golpe(t, a, 0.14)})`,
145
+ }}
146
+ >
147
+ {w}
148
+ </span>
149
+ ))}
150
+ </div>
151
+ );
152
+ }
153
+
154
+ if (S.tipo === 'caja') {
155
+ return (
156
+ <div style={{position: 'absolute', left: 70, width: W - 140, top: S.y, transform: `translateY(-50%) scale(${0.94 + 0.06 * resorte(entrada, 0.2)})`, opacity: clamp(entrada * 2), textAlign: 'center'}}>
157
+ <span
158
+ style={{
159
+ display: 'inline-block',
160
+ background: S.fondo ?? '#EAF3FF',
161
+ color: S.color,
162
+ fontFamily: tema.fuentes.texto,
163
+ fontWeight: 800,
164
+ fontSize: S.tam,
165
+ lineHeight: 1.25,
166
+ padding: '14px 30px',
167
+ borderRadius: 18,
168
+ boxShadow: '0 8px 22px rgba(0,0,0,0.28)',
169
+ }}
170
+ >
171
+ {g.map(([w, a], i) => (
172
+ <React.Fragment key={i}>
173
+ <span style={{opacity: t >= a - 0.04 ? 1 : 0.38, color: i === clave && S.resalte ? S.resalte : undefined}}>{texto(w)}</span>{' '}
174
+ </React.Fragment>
175
+ ))}
176
+ </span>
177
+ </div>
178
+ );
179
+ }
180
+
181
+ if (S.tipo === 'seleccion') {
182
+ const marco = S.resalte || '#4C8DFF';
183
+ const dichas = g.filter(([, a]) => t >= a - 0.03);
184
+ if (!dichas.length) return null;
185
+ return (
186
+ <div style={{position: 'absolute', left: 90, width: W - 180, top: S.y, transform: 'translateY(-50%)', textAlign: 'center', fontFamily: tema.fuentes.texto, fontSize: S.tam, lineHeight: 1.42, textShadow: '0 2px 10px rgba(0,0,0,0.45)'}}>
187
+ {dichas.map(([w, a, b], i) => {
188
+ const actual = t < b + 0.08 || i === dichas.length - 1;
189
+ const esClave = i === clave;
190
+ const k = resorte(clamp((t - a) / 0.3), 0.3);
86
191
  return (
87
- <span key={i} style={{fontFamily: tema.fuentes.titulo, fontWeight: 900, fontSize: S.tam * 1.5, color: activa ? tema.colores.acento : S.color, WebkitTextStroke: '10px #000', paintOrder: 'stroke fill', textTransform: 'uppercase', display: 'inline-block', transformOrigin: 'center bottom', transform: `scale(${activa ? 1.1 : 1})`}}>
88
- {w}
89
- </span>
192
+ <React.Fragment key={i}>
193
+ <span
194
+ style={{
195
+ position: 'relative',
196
+ display: 'inline-block',
197
+ color: actual || esClave ? S.color : S.pendiente,
198
+ fontWeight: actual || esClave ? 800 : 600,
199
+ opacity: clamp((t - a + 0.03) / 0.1),
200
+ padding: esClave ? '0 10px' : undefined,
201
+ margin: esClave ? '0 2px' : undefined,
202
+ }}
203
+ >
204
+ {esClave ? (
205
+ <span style={{position: 'absolute', inset: '4px 0', border: `3px solid ${marco}`, background: `${marco}33`, borderRadius: 3, transform: `scale(${0.7 + 0.3 * k})`, opacity: clamp(k * 2)}}>
206
+ <Asa x="0%" y="0%" color={marco} />
207
+ <Asa x="100%" y="100%" color={marco} />
208
+ </span>
209
+ ) : null}
210
+ <span style={{position: 'relative'}}>{texto(w)}</span>
211
+ </span>{' '}
212
+ </React.Fragment>
90
213
  );
91
214
  })}
92
215
  </div>
@@ -111,26 +234,26 @@ export const Subtitulos: React.FC<{tema: Tema; palabras: Palabra[]; t: number; d
111
234
  }}
112
235
  >
113
236
  <span style={S.fondo ? {background: S.fondo, padding: '10px 22px', borderRadius: 14, boxDecorationBreak: 'clone', WebkitBoxDecorationBreak: 'clone', lineHeight: 1.6} : undefined}>
114
- {g.map(([w, a, b], i) => {
115
- const dicha = t >= a;
116
- const actual = t >= a && t < b + 0.06;
117
- return (
118
- <React.Fragment key={i}>
119
- <span
120
- style={{
121
- color: dicha ? S.color : S.pendiente,
122
- fontWeight: actual ? 800 : 700,
123
- background: actual && S.resalte ? S.resalte : undefined,
124
- borderRadius: 10,
125
- padding: actual && S.resalte ? '2px 10px' : undefined,
126
- margin: actual && S.resalte ? '0 -10px' : undefined,
127
- }}
128
- >
129
- {w}
130
- </span>{' '}
131
- </React.Fragment>
132
- );
133
- })}
237
+ {g.map(([w, a, b], i) => {
238
+ const dicha = t >= a;
239
+ const actual = t >= a && t < b + 0.06;
240
+ return (
241
+ <React.Fragment key={i}>
242
+ <span
243
+ style={{
244
+ color: dicha ? S.color : S.pendiente,
245
+ fontWeight: actual ? 800 : 700,
246
+ background: actual && S.resalte ? S.resalte : undefined,
247
+ borderRadius: 10,
248
+ padding: actual && S.resalte ? '2px 10px' : undefined,
249
+ margin: actual && S.resalte ? '0 -10px' : undefined,
250
+ }}
251
+ >
252
+ {texto(w)}
253
+ </span>{' '}
254
+ </React.Fragment>
255
+ );
256
+ })}
134
257
  </span>
135
258
  </div>
136
259
  );