morfeo 0.2.1 → 0.3.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.
@@ -3,8 +3,9 @@
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, easeOutExpo, lerp, resorte} from '../estilo';
6
+ import {H, W, ancho, backOut, clamp, easeIO, easeOut, easeOutExpo, lerp, resorte} from '../estilo';
7
7
  import {ANOTACIONES, ANOTACIONES_SIN_ENTRADA, Anotacion} from './anotaciones';
8
+ import {FICHAS, Fichas} from './fichas';
8
9
  import {azar, cuando, formatoNumero, partesResaltadas, recurso, type CtxEscena} from './util';
9
10
  import {Particulas} from './fondo';
10
11
  import {Escena3D, Modelo3D} from './modelo3d';
@@ -278,10 +279,35 @@ const Contenido: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
278
279
  }
279
280
  default:
280
281
  if (ANOTACIONES.includes(c.tipo)) return <Anotacion c={c} ctx={ctx} />;
282
+ if (FICHAS.includes(c.tipo)) return <Fichas c={c} ctx={ctx} />;
281
283
  return <div style={{color: 'red', fontSize: 40, fontFamily: 'sans-serif', background: '#fff', padding: 10}}>capa desconocida: {c.tipo}</div>;
282
284
  }
283
285
  };
284
286
 
287
+ /**
288
+ * Tamaño de letra que cabe: si la palabra más larga no entra en el ancho disponible, o el texto ocuparía más de
289
+ * `maxLineas` renglones (3 por defecto), se achica. `ajustar: false` lo apaga. Mide con la fuente ya cargada.
290
+ */
291
+ function tamQueCabe(c: Capa, texto: string, fuente: string, T: CtxEscena['tema']): number {
292
+ let tam: number = c.tam ?? 96;
293
+ if (c.ajustar === false || !texto) return tam;
294
+ const relleno = c.fondo ? 60 : 0;
295
+ const disponible = (typeof c.ancho === 'number' ? c.ancho : typeof c.maxAncho === 'number' ? c.maxAncho : 940) - relleno;
296
+ const peso = c.peso ?? (fuente === T.fuentes.titulo ? 800 : 600);
297
+ const limpio = texto.replace(/\*/g, '');
298
+ try {
299
+ const palabras = limpio.split(/\s+/).filter(Boolean);
300
+ const masLarga = Math.max(...palabras.map((w) => ancho(w, fuente, tam, peso)));
301
+ if (masLarga > disponible) tam *= disponible / masLarga;
302
+ const maxLineas = c.maxLineas ?? 3;
303
+ const lineas = limpio.split('\n').reduce((n, l) => n + Math.max(1, Math.ceil((ancho(l, fuente, tam, peso) * 1.08) / disponible)), 0);
304
+ if (lineas > maxLineas) tam *= Math.sqrt(maxLineas / lineas);
305
+ } catch {
306
+ // la fuente aún no está cargada (p. ej. en el editor): se queda el tamaño pedido
307
+ }
308
+ return Math.max(28, Math.floor(tam));
309
+ }
310
+
285
311
  const TextoCapa: React.FC<{c: Capa; ctx: CtxEscena; fuente: string}> = ({c, ctx, fuente}) => {
286
312
  const T = ctx.tema;
287
313
  const may = c.mayusculas ?? (fuente === T.fuentes.titulo && T.tituloMayusculas);
@@ -298,11 +324,12 @@ const TextoCapa: React.FC<{c: Capa; ctx: CtxEscena; fuente: string}> = ({c, ctx,
298
324
  const color = c.color ?? T.colores.texto;
299
325
  const acento = c.acento ?? T.colores.acento;
300
326
  const contorno = c.contorno ? `${c.contorno}px ${c.colorContorno ?? '#000'}` : undefined;
327
+ const tam = tamQueCabe(c, texto, fuente, T);
301
328
  return (
302
329
  <div
303
330
  style={{
304
331
  fontFamily: fuente,
305
- fontSize: c.tam ?? 96,
332
+ fontSize: tam,
306
333
  fontWeight: c.peso ?? (fuente === T.fuentes.titulo ? 800 : 600),
307
334
  lineHeight: c.interlineado ?? 1.05,
308
335
  color,
@@ -0,0 +1,255 @@
1
+ // Tarjetas de datos para explicar con cosas que se entienden de un vistazo (estilo maqueta/infografía):
2
+ // reloj (carátula que se llena: horas del día, minutos), termometro, recipientes (garrafones o vasos que se llenan),
3
+ // ficha (credencial con foto y campos), checklist (se palomea en las palabras), mapa (plano con ruta y distancia).
4
+ import React from 'react';
5
+ import {Img} from 'remotion';
6
+ import {clamp, easeOut, lerp, resorte} from '../estilo';
7
+ import {cuando, formatoNumero, recurso, type CtxEscena} from './util';
8
+
9
+ type C = Record<string, any>;
10
+ const tarjeta = (c: C, extra: React.CSSProperties = {}): React.CSSProperties => ({
11
+ background: c.fondo ?? '#FFFFFF',
12
+ border: `${c.grosor ?? 6}px solid ${c.borde ?? '#1B1B1F'}`,
13
+ borderRadius: 30,
14
+ boxShadow: '0 14px 30px rgba(0,0,0,0.28)',
15
+ fontFamily: 'Montserrat, sans-serif',
16
+ color: c.colorTexto ?? '#1B1B1F',
17
+ ...extra,
18
+ });
19
+ /** 0..1 desde que empieza la capa (o desde `en`) durante `duracion` segundos. */
20
+ const avance = (c: C, ctx: CtxEscena, dur = 1.2, retraso = 0.25) => easeOut(clamp((ctx.t - cuando(c.en, ctx, 0) - retraso) / (c.duracion ?? dur)));
21
+
22
+ /** Reloj: un sector que se llena de `de` a `a` (horas de 0 a `max`, 24 por defecto). `segmentos` pinta tramos de colores. */
23
+ export const Reloj: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
24
+ const tam = c.tam ?? 300;
25
+ const max = c.max ?? 24;
26
+ const k = avance(c, ctx, c.duracion ?? 1.4);
27
+ const de = c.de ?? 0;
28
+ const a = lerp(de, c.a ?? c.valor ?? max / 4, k);
29
+ const r = tam / 2 - 22;
30
+ const cx = tam / 2;
31
+ const sector = (h0: number, h1: number, color: string, key: number) => {
32
+ if (h1 <= h0) return null;
33
+ const a0 = (h0 / max) * Math.PI * 2 - Math.PI / 2;
34
+ const a1 = (Math.min(h1, h0 + max * 0.9999) / max) * Math.PI * 2 - Math.PI / 2;
35
+ const p = (ang: number) => `${cx + Math.cos(ang) * r} ${cx + Math.sin(ang) * r}`;
36
+ return <path key={key} d={`M ${cx} ${cx} L ${p(a0)} A ${r} ${r} 0 ${a1 - a0 > Math.PI ? 1 : 0} 1 ${p(a1)} Z`} fill={color} />;
37
+ };
38
+ const segmentos: [number, number, string][] = c.segmentos ?? [[de, a, c.color ?? '#2D7BFF']];
39
+ const ang = (a / max) * 360;
40
+ return (
41
+ <div style={tarjeta(c, {width: tam + 40, padding: 20, borderRadius: 34, textAlign: 'center'})}>
42
+ <svg width={tam} height={tam}>
43
+ <circle cx={cx} cy={cx} r={r + 10} fill="#FFFFFF" stroke="#1B1B1F" strokeWidth={8} />
44
+ {segmentos.map(([h0, h1, color], i) => sector(h0, Math.min(h1, a), color, i))}
45
+ {Array.from({length: 12}, (_, i) => {
46
+ const g = (i / 12) * Math.PI * 2;
47
+ return <line key={i} x1={cx + Math.cos(g) * (r - 4)} y1={cx + Math.sin(g) * (r - 4)} x2={cx + Math.cos(g) * (r + 6)} y2={cx + Math.sin(g) * (r + 6)} stroke="#1B1B1F" strokeWidth={i % 3 ? 3 : 6} />;
48
+ })}
49
+ <line x1={cx} y1={cx} x2={cx + Math.cos(((ang - 90) * Math.PI) / 180) * r * 0.85} y2={cx + Math.sin(((ang - 90) * Math.PI) / 180) * r * 0.85} stroke="#1B1B1F" strokeWidth={8} strokeLinecap="round" />
50
+ <circle cx={cx} cy={cx} r={11} fill="#1B1B1F" />
51
+ </svg>
52
+ {c.etiqueta !== false ? <div style={{fontWeight: 900, fontSize: tam * 0.12, marginTop: 6}}>{c.etiqueta ?? `${max} H`}</div> : null}
53
+ </div>
54
+ );
55
+ };
56
+
57
+ /** Cronómetro: cuenta de `de` a `a` segundos (o minutos con `unidad: 'min'`) y su aro avanza. */
58
+ export const Cronometro: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
59
+ const tam = c.tam ?? 280;
60
+ const k = avance(c, ctx, c.duracion ?? 1.6);
61
+ const v = lerp(c.de ?? 0, c.a ?? 60, k);
62
+ const total = c.max ?? c.a ?? 60;
63
+ const r = tam / 2 - 26;
64
+ const cx = tam / 2;
65
+ const largo = 2 * Math.PI * r;
66
+ const mm = Math.floor(v / 60);
67
+ const ss = Math.floor(v % 60);
68
+ const texto = c.unidad === 'min' ? `${Math.round(v)} min` : `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
69
+ return (
70
+ <div style={{position: 'relative', width: tam, height: tam + 40}}>
71
+ <div style={{position: 'absolute', left: tam / 2 - 26, top: 0, width: 52, height: 34, background: c.color ?? '#E5484D', borderRadius: 10, border: '5px solid #1B1B1F'}} />
72
+ <svg width={tam} height={tam} style={{position: 'absolute', top: 36}}>
73
+ <circle cx={cx} cy={cx} r={r + 16} fill="#FFFFFF" stroke="#1B1B1F" strokeWidth={8} />
74
+ <circle cx={cx} cy={cx} r={r} fill="none" stroke="#E9ECF2" strokeWidth={18} />
75
+ <circle cx={cx} cy={cx} r={r} fill="none" stroke={c.color ?? '#E5484D'} strokeWidth={18} strokeDasharray={`${largo * clamp(v / total)} ${largo}`} transform={`rotate(-90 ${cx} ${cx})`} strokeLinecap="round" />
76
+ <text x={cx} y={cx + tam * 0.06} textAnchor="middle" fontFamily="Montserrat, sans-serif" fontWeight={900} fontSize={tam * 0.17} fill="#1B1B1F">
77
+ {texto}
78
+ </text>
79
+ </svg>
80
+ </div>
81
+ );
82
+ };
83
+
84
+ /** Termómetro que sube hasta `valor` (de `min` a `max`). */
85
+ export const Termometro: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
86
+ const alto = c.alto ?? 420;
87
+ const min = c.min ?? 0;
88
+ const max = c.max ?? 50;
89
+ const k = avance(c, ctx, 1.3);
90
+ const v = lerp(c.de ?? min, c.valor ?? 38, k);
91
+ const nivel = clamp((v - min) / (max - min));
92
+ const color = c.color ?? (nivel > 0.66 ? '#E5484D' : nivel > 0.4 ? '#F5B841' : '#2D7BFF');
93
+ const tubo = alto - 110;
94
+ return (
95
+ <div style={tarjeta(c, {width: 170, padding: '24px 0 18px', display: 'flex', flexDirection: 'column', alignItems: 'center'})}>
96
+ <div style={{position: 'relative', width: 60, height: tubo, background: '#EEF1F6', border: '6px solid #1B1B1F', borderRadius: 30, overflow: 'hidden'}}>
97
+ <div style={{position: 'absolute', left: 0, right: 0, bottom: 0, height: `${nivel * 100}%`, background: color}} />
98
+ </div>
99
+ <div style={{width: 92, height: 92, marginTop: -14, borderRadius: '50%', background: color, border: '6px solid #1B1B1F', zIndex: 1}} />
100
+ {c.mostrar !== false ? (
101
+ <div style={{fontWeight: 900, fontSize: 40, marginTop: 10}}>
102
+ {formatoNumero(v, c.decimales ?? 0)}
103
+ {c.unidad ?? '°C'}
104
+ </div>
105
+ ) : null}
106
+ </div>
107
+ );
108
+ };
109
+
110
+ /** Recipientes (garrafones, vasos, cubetas) que se llenan uno tras otro hasta `valor` de `n`; `total` debajo ("50 L"). */
111
+ export const Recipientes: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
112
+ const n = c.n ?? 3;
113
+ const valor = c.valor ?? n;
114
+ const k = avance(c, ctx, c.duracion ?? 0.6 * n);
115
+ const lleno = valor * k;
116
+ const color = c.color ?? '#3B8CFF';
117
+ const forma = c.forma ?? 'garrafon';
118
+ const w = c.tamPieza ?? 110;
119
+ const h = forma === 'vaso' ? w * 1.2 : w * 1.6;
120
+ return (
121
+ <div style={tarjeta(c, {padding: '26px 30px 18px', display: 'inline-flex', flexDirection: 'column', alignItems: 'center'})}>
122
+ <div style={{display: 'flex', gap: 22, alignItems: 'flex-end'}}>
123
+ {Array.from({length: n}, (_, i) => {
124
+ const nivel = clamp(lleno - i);
125
+ return (
126
+ <div key={i} style={{display: 'flex', flexDirection: 'column', alignItems: 'center'}}>
127
+ {forma === 'garrafon' ? <div style={{width: w * 0.32, height: w * 0.22, background: color, border: '5px solid #1B1B1F', borderRadius: 6, marginBottom: -5}} /> : null}
128
+ <div style={{position: 'relative', width: w, height: h, border: '6px solid #1B1B1F', borderRadius: forma === 'vaso' ? '8px 8px 24px 24px' : `${w * 0.3}px ${w * 0.3}px 18px 18px`, background: '#F4F8FF', overflow: 'hidden'}}>
129
+ <div style={{position: 'absolute', left: 0, right: 0, bottom: 0, height: `${nivel * 100}%`, background: color, opacity: 0.9}} />
130
+ <div style={{position: 'absolute', left: '18%', top: '12%', width: '14%', height: '60%', background: 'rgba(255,255,255,0.55)', borderRadius: 20}} />
131
+ </div>
132
+ </div>
133
+ );
134
+ })}
135
+ </div>
136
+ {c.total ? <div style={{fontWeight: 900, fontSize: 44, marginTop: 12}}>{c.total}</div> : null}
137
+ </div>
138
+ );
139
+ };
140
+
141
+ /**
142
+ * Ficha o credencial: cabecera de color, foto (imagen/recurso) a la izquierda y campos [etiqueta, valor].
143
+ * campos: [['NOMBRE', 'Buey prieto'], ['SEXO', 'Macho']]
144
+ */
145
+ export const Ficha: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
146
+ const ancho = c.ancho ?? 640;
147
+ const campos: [string, string][] = c.campos ?? [];
148
+ const t0 = cuando(c.en, ctx, 0);
149
+ return (
150
+ <div style={tarjeta(c, {width: ancho, overflow: 'hidden', padding: 0, borderRadius: 26})}>
151
+ <div style={{background: c.color ?? '#7A4FD1', color: '#FFFFFF', fontWeight: 900, fontSize: 30, letterSpacing: 1.5, textAlign: 'center', padding: '12px 10px', textTransform: 'uppercase'}}>{c.titulo ?? 'Credencial'}</div>
152
+ <div style={{display: 'flex', gap: 22, padding: 22, alignItems: 'center'}}>
153
+ {c.foto ? (
154
+ <div style={{width: ancho * 0.3, height: ancho * 0.3, borderRadius: 18, border: '5px solid #1B1B1F', background: c.fondoFoto ?? '#EDE6F7', overflow: 'hidden', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
155
+ <Img src={recurso(c.foto)} style={{width: '90%', height: '90%', objectFit: 'contain'}} />
156
+ </div>
157
+ ) : null}
158
+ <div style={{display: 'flex', flexDirection: 'column', gap: 8}}>
159
+ {campos.map(([et, v], i) => {
160
+ const k = clamp((ctx.t - t0 - 0.3 - i * 0.18) / 0.3);
161
+ return (
162
+ <div key={i} style={{opacity: k, transform: `translateX(${(1 - k) * 20}px)`}}>
163
+ <div style={{fontSize: 20, fontWeight: 700, color: '#8A8A99', letterSpacing: 1, textTransform: 'uppercase'}}>{et}</div>
164
+ <div style={{fontSize: 34, fontWeight: 900, lineHeight: 1.1, textTransform: 'uppercase'}}>{v}</div>
165
+ </div>
166
+ );
167
+ })}
168
+ </div>
169
+ </div>
170
+ </div>
171
+ );
172
+ };
173
+
174
+ /**
175
+ * Lista que se palomea: items [{texto, en: 'palabra'}] (o texto suelto). Los que no tienen `en` se palomean en orden.
176
+ * Con `pendiente: true` un item queda gris y sin palomita (lo que falta).
177
+ */
178
+ export const Checklist: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
179
+ const items: {texto: string; en?: unknown; pendiente?: boolean}[] = (c.items ?? []).map((x: any) => (typeof x === 'string' ? {texto: x} : x));
180
+ const t0 = cuando(c.en, ctx, 0);
181
+ const tam = c.tam ?? 46;
182
+ return (
183
+ <div style={tarjeta(c, {padding: '22px 32px', display: 'inline-flex', flexDirection: 'column', gap: 10})}>
184
+ {c.titulo ? <div style={{fontWeight: 900, fontSize: tam * 0.7, color: '#8A8A99', letterSpacing: 1, textTransform: 'uppercase'}}>{c.titulo}</div> : null}
185
+ {items.map((it, i) => {
186
+ const tc = it.en != null ? cuando(it.en, ctx, 0) : t0 + 0.4 + i * (c.cada ?? 0.45);
187
+ const k = it.pendiente ? 0 : clamp((ctx.t - tc) / 0.25);
188
+ const color = it.pendiente ? '#B9BCC6' : c.color ?? '#2BB673';
189
+ return (
190
+ <div key={i} style={{display: 'flex', alignItems: 'center', gap: 18, fontWeight: 900, fontSize: tam, color: it.pendiente ? '#B9BCC6' : '#1B1B1F', textTransform: 'uppercase'}}>
191
+ <span style={{color}}>{it.texto}</span>
192
+ <svg width={tam * 0.9} height={tam * 0.9} viewBox="0 0 40 40" style={{transform: `scale(${resorte(k, 0.4)})`}}>
193
+ <path d="M 6 21 L 16 31 L 35 9" stroke={color} strokeWidth={7} fill="none" strokeLinecap="round" strokeLinejoin="round" />
194
+ </svg>
195
+ </div>
196
+ );
197
+ })}
198
+ </div>
199
+ );
200
+ };
201
+
202
+ /**
203
+ * Mapa mini: un plano inclinado con una ruta que se dibuja de `de` a `a` (coordenadas 0..1 del plano) y la distancia.
204
+ * `ruta`: puntos intermedios [[x, y], ...]; `distancia`: '850 M'.
205
+ */
206
+ export const Mapa: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
207
+ const ancho = c.ancho ?? 620;
208
+ const alto = ancho * 0.62;
209
+ const k = avance(c, ctx, c.duracion ?? 1.4, 0.4);
210
+ const pts: [number, number][] = c.ruta ?? [
211
+ [0.12, 0.7],
212
+ [0.35, 0.45],
213
+ [0.6, 0.55],
214
+ [0.88, 0.25],
215
+ ];
216
+ const d = pts.map(([x, y], i) => `${i ? 'L' : 'M'} ${x * ancho} ${y * alto}`).join(' ');
217
+ const largo = pts.reduce((a, [x, y], i) => (i ? a + Math.hypot((x - pts[i - 1][0]) * ancho, (y - pts[i - 1][1]) * alto) : 0), 0);
218
+ const fin = pts[pts.length - 1];
219
+ return (
220
+ <div style={{position: 'relative', width: ancho, height: alto, transform: `perspective(1400px) rotateX(${c.inclinar ?? 38}deg) rotateZ(-8deg)`, transformOrigin: 'center'}}>
221
+ <svg width={ancho} height={alto} style={{background: c.fondo ?? '#7CC46A', borderRadius: 18, border: '8px solid #FFFFFF', boxShadow: '0 30px 40px rgba(0,0,0,0.25)'}}>
222
+ {Array.from({length: 40}, (_, i) => (
223
+ <circle key={i} cx={((i * 97) % 100) / 100 * ancho} cy={((i * 61) % 100) / 100 * alto} r={10 + (i % 4) * 4} fill="#5BA84C" opacity={0.7} />
224
+ ))}
225
+ <path d={d} stroke={c.color ?? '#2D7BFF'} strokeWidth={22} fill="none" strokeLinecap="round" strokeLinejoin="round" strokeDasharray={`${largo * k} ${largo}`} />
226
+ {k > 0.98 ? <circle cx={fin[0] * ancho} cy={fin[1] * alto} r={18} fill="#E5484D" stroke="#FFFFFF" strokeWidth={6} /> : null}
227
+ </svg>
228
+ {c.distancia ? (
229
+ <div style={{position: 'absolute', left: 0, right: 0, top: alto * 0.08, textAlign: 'center', fontFamily: 'Montserrat, sans-serif', fontWeight: 900, fontSize: 54, color: '#FFFFFF', textShadow: '0 4px 0 rgba(0,0,0,0.35)', opacity: k}}>
230
+ {c.distancia}
231
+ </div>
232
+ ) : null}
233
+ </div>
234
+ );
235
+ };
236
+
237
+ export const FICHAS = ['reloj', 'cronometro', 'termometro', 'recipientes', 'ficha', 'checklist', 'mapa'];
238
+ export const Fichas: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
239
+ switch (c.tipo) {
240
+ case 'reloj':
241
+ return <Reloj c={c} ctx={ctx} />;
242
+ case 'cronometro':
243
+ return <Cronometro c={c} ctx={ctx} />;
244
+ case 'termometro':
245
+ return <Termometro c={c} ctx={ctx} />;
246
+ case 'recipientes':
247
+ return <Recipientes c={c} ctx={ctx} />;
248
+ case 'ficha':
249
+ return <Ficha c={c} ctx={ctx} />;
250
+ case 'checklist':
251
+ return <Checklist c={c} ctx={ctx} />;
252
+ default:
253
+ return <Mapa c={c} ctx={ctx} />;
254
+ }
255
+ };
@@ -19,7 +19,7 @@ import {OBJETOS_3D, Objeto3D} from './objetos3d';
19
19
  const RAD = Math.PI / 180;
20
20
  type Gltf = {scene: THREE.Group; animations: THREE.AnimationClip[]};
21
21
  const cache = new Map<string, Promise<Gltf>>();
22
- const cargarGltf = (url: string) => {
22
+ export const cargarGltf = (url: string) => {
23
23
  if (!cache.has(url)) cache.set(url, new GLTFLoader().loadAsync(url).then((g) => ({scene: g.scene, animations: g.animations})));
24
24
  return cache.get(url)!;
25
25
  };
@@ -74,7 +74,7 @@ export const Subtitulos: React.FC<{tema: Tema; palabras: Palabra[]; t: number; d
74
74
  if (!g || t < g[0][1] - 0.12) return null;
75
75
  const entrada = clamp((t - (g[0][1] - 0.12)) / 0.18);
76
76
  const clave = claveDe(g, claves);
77
- const texto = (w: string) => (S.mayusculas ? w.toUpperCase() : w);
77
+ const texto = (w: string) => (S.mayusculas === 'inicial' ? w.charAt(0).toUpperCase() + w.slice(1) : S.mayusculas ? w.toUpperCase() : w);
78
78
 
79
79
  if (S.tipo === 'palabra') {
80
80
  const [w] = g[0];
@@ -82,7 +82,7 @@ export const Subtitulos: React.FC<{tema: Tema; palabras: Palabra[]; t: number; d
82
82
  const acento = claveDe([g[0]], claves) === 0;
83
83
  return (
84
84
  <div style={{position: 'absolute', left: 40, width: W - 80, top: S.y, transform: `translateY(-50%) scale(${resorte(entrada, 0.25)})`, textAlign: 'center'}}>
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)'}}>
85
+ <span style={{fontFamily: tema.fuentes.texto, fontWeight: 900, fontSize: S.tam * 1.25, color: acento ? tema.colores.acento : S.color, WebkitTextStroke: S.contorno === 0 ? undefined : `${S.contorno ?? 9}px #000`, paintOrder: 'stroke fill', textShadow: S.contorno === 0 ? '0 3px 3px rgba(0,0,0,0.45), 0 6px 22px rgba(0,0,0,0.35)' : '0 6px 0 rgba(0,0,0,0.35)'}}>
86
86
  {texto(w)}
87
87
  </span>
88
88
  </div>
@@ -38,7 +38,9 @@ export type Tema = {
38
38
  * palabra: una palabra a la vez · caja: la frase completa en una etiqueta clara.
39
39
  */
40
40
  tipo: 'karaoke' | 'seleccion' | 'pop' | 'bloque' | 'palabra' | 'caja' | 'ninguno';
41
- mayusculas?: boolean;
41
+ /** true = MAYÚSCULAS · 'inicial' = Primera Letra (como en los videos de maqueta) */
42
+ mayusculas?: boolean | 'inicial';
43
+ /** 0 = sin contorno: sombra suave en su lugar */
42
44
  contorno?: number;
43
45
  fondo?: string; // caja detrás de la frase (karaoke)
44
46
  y: number; // centro vertical (px de 1920)
@@ -58,6 +60,8 @@ export type Tema = {
58
60
  desenfoqueBordes?: number; // px de blur arriba y abajo (efecto maqueta / profundidad)
59
61
  vinetaColor?: string;
60
62
  };
63
+ /** Rótulo grande de cada escena: 'normal' (color del texto), 'contorno' (blanco con contorno oscuro, clave en acento) o 'pastilla' (caja oscura inclinada). */
64
+ rotulo?: {estilo?: 'normal' | 'contorno' | 'pastilla'; inclinar?: number; y?: number; tam?: number};
61
65
  marca: 'esquina' | 'arriba' | 'ninguna';
62
66
  /** Movimiento suave de cámara por defecto en cada escena (zoom de 1 a este valor). */
63
67
  zoomLento: number;
@@ -197,6 +201,21 @@ export const ESTILOS: Record<string, Tema> = {
197
201
  marca: 'esquina',
198
202
  zoomLento: 1.04,
199
203
  },
204
+ // Maqueta 3D low-poly explicada (diorama con corte de tierra): una palabra a la vez al centro, rótulos con contorno,
205
+ // etiquetas con flecha pegadas a los objetos y cortes rápidos entre ángulos del mismo mundo
206
+ maqueta: {
207
+ fondo: {tipo: 'degradado', colores: ['#BFE3FF', '#EAF6FF']},
208
+ vineta: 0.06,
209
+ colores: {texto: '#FFFFFF', acento: '#FFC928', acento2: '#2D7BFF', suave: '#E8EEF6', tarjeta: '#FFFFFF', tarjetaTexto: '#1B1B1F', borde: '#1B1B1F'},
210
+ fuentes: {titulo: MONT, texto: MONT, mono: MONO},
211
+ tituloMayusculas: true,
212
+ subtitulos: {tipo: 'palabra', y: 1010, tam: 52, maxPalabras: 1, mayusculas: 'inicial', contorno: 0, color: '#FFFFFF', pendiente: '#FFFFFF', resalte: ''},
213
+ rotulo: {estilo: 'contorno', inclinar: -2, y: 330, tam: 92},
214
+ transicion: 'corte',
215
+ movimiento: {entrada: 0.4, salida: 0.25, transicion: 0.2, curva: 'resorte'},
216
+ marca: 'ninguna',
217
+ zoomLento: 1.03,
218
+ },
200
219
  // Oscuro y limpio, para lo que sea
201
220
  minimal: {
202
221
  fondo: {tipo: 'degradado', colores: ['#15151C', '#08080C']},