morfeo 0.1.2 → 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.
@@ -0,0 +1,296 @@
1
+ // Anotaciones: capas que señalan y explican lo que hay en pantalla, como en un documental o un tutorial.
2
+ // dato cifra grande + unidad en una pastilla oscura: {tipo: 'dato', valor: 57, unidad: 'toneladas'}
3
+ // medida línea de cota con topes y su etiqueta: {tipo: 'medida', de: [200, 900], a: [700, 300], texto: '18 m'}
4
+ // trazo línea o flecha que se dibuja por varios puntos: {tipo: 'trazo', puntos: [[x, y], ...], flecha: true, punteado: true}
5
+ // contorno marco punteado que "camina" alrededor de algo: {tipo: 'contorno', ancho: 400, alto: 300} (forma: 'rect' | 'circulo' | 'aro')
6
+ // fuente cita pequeña de dónde sale el dato: {tipo: 'fuente', texto: 'Cook et al., 2016 · Science'}
7
+ // seccion etiqueta de capítulo en monoespaciada: {tipo: 'seccion', texto: 'paso 2 · la carpeta'}
8
+ // insignia número de paso en un cuadro oscuro: {tipo: 'insignia', texto: 2}
9
+ // sello sello de goma inclinado: {tipo: 'sello', texto: 'AUTOMÁTICO'}
10
+ // cursor puntero que viaja y hace clic: {tipo: 'cursor', a: [620, 900], clic: 'carpeta'}
11
+ // `medida` y `trazo` usan coordenadas del lienzo (1080×1920). Todas aceptan `en`, `hasta` y `sigue: 'id'`.
12
+ import React from 'react';
13
+ import {clamp, easeIO, easeOut, lerp, resorte} from '../estilo';
14
+ import {cuando, formatoNumero, type CtxEscena} from './util';
15
+
16
+ type Capa = Record<string, any> & {tipo: string};
17
+
18
+ // nombre etiqueta de personaje con su rol: {tipo: 'nombre', texto: 'DOÑA ROSA', sub: 'la mamá · 48 años'}
19
+ // accion lo que hace el protagonista en ese momento: {tipo: 'accion', texto: 'Te despiertas'}
20
+ // terminal ventana de terminal que se escribe sola: {tipo: 'terminal', lineas: [{texto: 'npm install morfeo', en: 'instala'}, {texto: '✔ listo', salida: true, en: 'fin'}]}
21
+ export const ANOTACIONES = ['dato', 'medida', 'trazo', 'contorno', 'fuente', 'seccion', 'insignia', 'sello', 'cursor', 'terminal', 'nombre', 'accion'];
22
+ /** Las que se dibujan solas (línea que avanza): no llevan animación de entrada de capa. */
23
+ export const ANOTACIONES_SIN_ENTRADA = ['medida', 'trazo', 'contorno', 'cursor'];
24
+
25
+ const largoDe = (pts: [number, number][]) => pts.slice(1).reduce((s, p, i) => s + Math.hypot(p[0] - pts[i][0], p[1] - pts[i][1]), 0);
26
+
27
+ /** Pastilla oscura translúcida: la base de `dato` y de la etiqueta de `medida`. */
28
+ const Pastilla: React.FC<{children: React.ReactNode; color?: string; tam?: number}> = ({children, color, tam = 1}) => (
29
+ <div
30
+ style={{
31
+ display: 'inline-flex',
32
+ alignItems: 'baseline',
33
+ gap: 10 * tam,
34
+ padding: `${10 * tam}px ${22 * tam}px`,
35
+ background: color ?? 'rgba(10, 14, 24, 0.82)',
36
+ border: '2px solid rgba(255,255,255,0.14)',
37
+ borderRadius: 16 * tam,
38
+ boxShadow: '0 10px 26px rgba(0,0,0,0.35)',
39
+ whiteSpace: 'nowrap',
40
+ }}
41
+ >
42
+ {children}
43
+ </div>
44
+ );
45
+
46
+ export const Anotacion: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
47
+ const T = ctx.tema;
48
+ const t = ctx.t - cuando(c.en, ctx, 0);
49
+ // origen de la capa en el lienzo: `medida` y `trazo` reciben puntos absolutos y hay que restarlo
50
+ const ox = c.x ?? 540;
51
+ const oy = c.y ?? 960;
52
+ const color = c.color ?? T.colores.acento;
53
+
54
+ switch (c.tipo) {
55
+ case 'dato': {
56
+ const tam = (c.tam ?? 64) / 64;
57
+ const k = c.de != null ? easeOut(clamp(t / (c.duracion ?? 0.9))) : 1;
58
+ const v = typeof c.valor === 'number' ? formatoNumero(lerp(c.de ?? c.valor, c.valor, k), c.decimales ?? 0) : String(c.valor ?? c.texto ?? '');
59
+ return (
60
+ <Pastilla color={c.fondo} tam={tam}>
61
+ <span style={{fontFamily: T.fuentes.texto, fontWeight: 900, fontSize: 64 * tam, lineHeight: 1, color}}>{v}</span>
62
+ {c.unidad ? <span style={{fontFamily: T.fuentes.texto, fontWeight: 700, fontSize: 30 * tam, color: '#fff', opacity: 0.92}}>{c.unidad}</span> : null}
63
+ </Pastilla>
64
+ );
65
+ }
66
+
67
+ case 'medida': {
68
+ const [x1, y1] = c.de ?? [ox - 200, oy];
69
+ const [x2, y2] = c.a ?? [ox + 200, oy];
70
+ const k = easeIO(clamp(t / (c.duracion ?? 0.6)));
71
+ const xa = lerp(x1, x2, k);
72
+ const ya = lerp(y1, y2, k);
73
+ const ang = Math.atan2(y2 - y1, x2 - x1);
74
+ const nx = Math.cos(ang + Math.PI / 2) * (c.tope ?? 16);
75
+ const ny = Math.sin(ang + Math.PI / 2) * (c.tope ?? 16);
76
+ const g = c.grosor ?? 5;
77
+ // la etiqueta va a un lado de la mitad de la línea
78
+ const sep = c.separacion ?? 70;
79
+ const lx = (x1 + x2) / 2 - Math.cos(ang + Math.PI / 2) * sep;
80
+ const ly = (y1 + y2) / 2 - Math.sin(ang + Math.PI / 2) * sep;
81
+ const ke = resorte(clamp((t - (c.duracion ?? 0.6) * 0.6) / 0.4), 0.3);
82
+ return (
83
+ <div style={{position: 'relative', width: 0, height: 0}}>
84
+ <svg style={{position: 'absolute', left: -ox, top: -oy, overflow: 'visible'}} width={1} height={1}>
85
+ <g stroke={color} strokeWidth={g} strokeLinecap="round" style={{filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.4))'}}>
86
+ <line x1={x1} y1={y1} x2={xa} y2={ya} />
87
+ <line x1={x1 - nx} y1={y1 - ny} x2={x1 + nx} y2={y1 + ny} />
88
+ {k > 0.98 ? <line x1={x2 - nx} y1={y2 - ny} x2={x2 + nx} y2={y2 + ny} /> : null}
89
+ </g>
90
+ </svg>
91
+ {c.texto != null && ke > 0.01 ? (
92
+ <div style={{position: 'absolute', left: lx - ox, top: ly - oy, transform: `translate(-50%, -50%) scale(${ke})`}}>
93
+ <Pastilla tam={(c.tam ?? 46) / 64}>
94
+ <span style={{fontFamily: T.fuentes.texto, fontWeight: 900, fontSize: c.tam ?? 46, lineHeight: 1, color}}>{c.texto}</span>
95
+ {c.unidad ? <span style={{fontFamily: T.fuentes.texto, fontWeight: 700, fontSize: (c.tam ?? 46) * 0.5, color: '#fff'}}>{c.unidad}</span> : null}
96
+ </Pastilla>
97
+ </div>
98
+ ) : null}
99
+ </div>
100
+ );
101
+ }
102
+
103
+ case 'trazo': {
104
+ const pts: [number, number][] = c.puntos ?? [];
105
+ if (pts.length < 2) return null;
106
+ const L = largoDe(pts);
107
+ const k = easeIO(clamp(t / (c.duracion ?? Math.max(0.4, L / 1400))));
108
+ const g = c.grosor ?? 6;
109
+ const d = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x} ${y}`).join(' ');
110
+ const [px, py] = pts[pts.length - 1];
111
+ const [qx, qy] = pts[pts.length - 2];
112
+ const ang = (Math.atan2(py - qy, px - qx) * 180) / Math.PI;
113
+ const guion = c.punteado ? (typeof c.punteado === 'number' ? c.punteado : g * 2.6) : 0;
114
+ return (
115
+ <svg style={{position: 'absolute', left: -ox, top: -oy, overflow: 'visible'}} width={1} height={1}>
116
+ <defs>
117
+ <mask id={`trazo-${c.id ?? `${pts[0][0]}-${pts[0][1]}`}`} maskUnits="userSpaceOnUse" x={-200} y={-200} width={1480} height={2320}>
118
+ <path d={d} fill="none" stroke="#fff" strokeWidth={g + 8} strokeDasharray={L} strokeDashoffset={L * (1 - k)} strokeLinecap="round" strokeLinejoin="round" />
119
+ </mask>
120
+ </defs>
121
+ <g mask={`url(#trazo-${c.id ?? `${pts[0][0]}-${pts[0][1]}`})`} style={{filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.35))'}}>
122
+ <path d={d} fill="none" stroke={color} strokeWidth={g} strokeLinecap="round" strokeLinejoin="round" strokeDasharray={guion ? `${guion} ${guion}` : undefined} strokeDashoffset={guion ? -ctx.t * (c.velocidad ?? 40) : undefined} />
123
+ </g>
124
+ {c.flecha && k > 0.97 ? <polygon points={`0,0 ${-g * 4.2},${-g * 2.4} ${-g * 4.2},${g * 2.4}`} fill={color} transform={`translate(${px} ${py}) rotate(${ang})`} /> : null}
125
+ </svg>
126
+ );
127
+ }
128
+
129
+ case 'contorno': {
130
+ const w = c.ancho ?? 400;
131
+ const h = c.alto ?? (c.forma === 'aro' ? w * 0.32 : c.forma === 'circulo' ? w : 300);
132
+ const g = c.grosor ?? 5;
133
+ const k = easeOut(clamp(t / (c.duracion ?? 0.45)));
134
+ const guion = c.punteado === false ? undefined : `${g * 3.2} ${g * 2.6}`;
135
+ const desfase = -ctx.t * (c.velocidad ?? 46);
136
+ const comun = {fill: c.relleno ?? 'none', stroke: color, strokeWidth: g, strokeDasharray: guion, strokeDashoffset: desfase, strokeLinecap: 'round' as const};
137
+ const m = g + 6;
138
+ return (
139
+ <svg width={w + m * 2} height={h + m * 2} style={{display: 'block', overflow: 'visible', opacity: k, transform: `scale(${lerp(1.12, 1, k)})`, filter: c.forma === 'aro' ? `drop-shadow(0 0 14px ${color})` : 'drop-shadow(0 2px 5px rgba(0,0,0,0.35))'}}>
140
+ {c.forma === 'circulo' || c.forma === 'aro' ? <ellipse cx={w / 2 + m} cy={h / 2 + m} rx={w / 2} ry={h / 2} {...comun} /> : <rect x={m} y={m} width={w} height={h} rx={c.radio ?? 14} {...comun} />}
141
+ </svg>
142
+ );
143
+ }
144
+
145
+ case 'fuente':
146
+ return (
147
+ <div style={{borderLeft: `4px solid ${color}`, paddingLeft: 14, fontFamily: T.fuentes.mono, color: c.colorTexto ?? T.colores.texto, opacity: 0.86, textShadow: T.colores.texto === '#FFFFFF' ? '0 2px 8px rgba(0,0,0,0.6)' : undefined}}>
148
+ <div style={{fontSize: (c.tam ?? 24) * 0.72, letterSpacing: 3, color, fontWeight: 700}}>{c.etiqueta ?? 'FUENTE'}</div>
149
+ <div style={{fontSize: c.tam ?? 24, whiteSpace: 'pre', marginTop: 4}}>{c.texto}</div>
150
+ </div>
151
+ );
152
+
153
+ case 'seccion':
154
+ return (
155
+ <div style={{fontFamily: T.fuentes.mono, fontSize: c.tam ?? 30, color: c.color ?? T.colores.suave, whiteSpace: 'pre', letterSpacing: 0.5}}>
156
+ <span style={{opacity: 0.7}}>{'// '}</span>
157
+ {c.texto}
158
+ </div>
159
+ );
160
+
161
+ case 'insignia': {
162
+ const tam = c.tam ?? 96;
163
+ const claro = T.colores.texto === '#FFFFFF';
164
+ return (
165
+ <div
166
+ style={{
167
+ width: tam,
168
+ height: tam * 1.08,
169
+ background: c.fondo ?? (claro ? '#fff' : '#161616'),
170
+ color: c.colorTexto ?? (claro ? '#111' : '#fff'),
171
+ fontFamily: T.fuentes.titulo,
172
+ fontSize: tam * 0.64,
173
+ lineHeight: 1,
174
+ display: 'flex',
175
+ flexDirection: 'column',
176
+ alignItems: 'center',
177
+ justifyContent: 'center',
178
+ gap: tam * 0.05,
179
+ borderRadius: tam * 0.14,
180
+ boxShadow: '0 8px 0 rgba(0,0,0,0.18), 0 14px 22px rgba(0,0,0,0.22)',
181
+ transform: `rotate(${c.inclinar ?? 0}deg)`,
182
+ }}
183
+ >
184
+ <span>{c.texto ?? c.valor}</span>
185
+ <span style={{display: 'flex', gap: tam * 0.07}}>
186
+ {[0, 1, 2].map((i) => (
187
+ <span key={i} style={{width: tam * 0.06, height: tam * 0.06, borderRadius: '50%', background: 'currentColor', opacity: 0.45}} />
188
+ ))}
189
+ </span>
190
+ </div>
191
+ );
192
+ }
193
+
194
+ case 'sello': {
195
+ const tam = c.tam ?? 52;
196
+ // golpe de sello: cae grande y se asienta con una vibración corta
197
+ const k = clamp(t / 0.22);
198
+ const escala = lerp(1.9, 1, easeOut(k)) * (k >= 1 ? 1 + 0.03 * Math.exp(-(t - 0.22) * 14) * Math.sin((t - 0.22) * 60) : 1);
199
+ return (
200
+ <div
201
+ style={{
202
+ transform: `rotate(${c.inclinar ?? -7}deg) scale(${escala})`,
203
+ opacity: clamp(k * 3) * 0.92,
204
+ border: `${Math.max(4, tam * 0.1)}px solid ${color}`,
205
+ outline: `${Math.max(2, tam * 0.04)}px solid ${color}`,
206
+ outlineOffset: tam * 0.1,
207
+ borderRadius: tam * 0.2,
208
+ padding: `${tam * 0.12}px ${tam * 0.42}px`,
209
+ fontFamily: T.fuentes.titulo,
210
+ fontSize: tam,
211
+ lineHeight: 1.1,
212
+ letterSpacing: 2,
213
+ color,
214
+ textTransform: 'uppercase',
215
+ whiteSpace: 'nowrap',
216
+ }}
217
+ >
218
+ {c.texto}
219
+ </div>
220
+ );
221
+ }
222
+
223
+ case 'terminal': {
224
+ // ventana de terminal: cada línea se escribe letra por letra a partir de su `en`
225
+ const w = c.ancho ?? 820;
226
+ const tam = c.tam ?? 34;
227
+ const lineas: any[] = (c.lineas ?? []).map((l: any) => (typeof l === 'string' ? {texto: l} : l));
228
+ return (
229
+ <div style={{width: w, background: c.fondo ?? '#16171D', borderRadius: 22, boxShadow: '0 26px 50px rgba(0,0,0,0.28), 0 4px 0 rgba(0,0,0,0.25)', overflow: 'hidden', border: '2px solid rgba(255,255,255,0.08)'}}>
230
+ <div style={{display: 'flex', alignItems: 'center', gap: 10, padding: '16px 20px', background: 'rgba(255,255,255,0.06)'}}>
231
+ {['#FF5F57', '#FEBC2E', '#28C840'].map((k) => (
232
+ <span key={k} style={{width: 16, height: 16, borderRadius: '50%', background: k}} />
233
+ ))}
234
+ <span style={{marginLeft: 12, fontFamily: T.fuentes.mono, fontSize: tam * 0.62, color: 'rgba(255,255,255,0.45)'}}>{c.titulo ?? 'terminal'}</span>
235
+ </div>
236
+ <div style={{padding: `${tam * 0.7}px ${tam * 0.8}px ${tam}px`, fontFamily: T.fuentes.mono, fontSize: tam, lineHeight: 1.55, minHeight: tam * 1.55 * Math.max(2, lineas.length)}}>
237
+ {lineas.map((l, i) => {
238
+ const tl = ctx.t - cuando(l.en, ctx, cuando(c.en, ctx, 0) + i * 0.6);
239
+ if (tl < 0) return null;
240
+ const salida = l.salida === true;
241
+ const n = salida ? l.texto.length : Math.floor(tl * (c.velocidad ?? 24));
242
+ const escribiendo = !salida && n <= l.texto.length + 6;
243
+ return (
244
+ <div key={i} style={{color: l.color ?? (salida ? '#7BE08A' : c.colorTexto ?? '#F2F2F5'), whiteSpace: 'pre-wrap', opacity: salida ? clamp(tl / 0.2) : 1}}>
245
+ {salida ? '' : <span style={{color}}>{'$ '}</span>}
246
+ {l.texto.slice(0, n)}
247
+ {escribiendo && Math.floor(ctx.t * 3) % 2 === 0 ? <span style={{background: c.colorTexto ?? '#F2F2F5', color: 'transparent'}}>_</span> : null}
248
+ </div>
249
+ );
250
+ })}
251
+ </div>
252
+ </div>
253
+ );
254
+ }
255
+
256
+ case 'nombre': {
257
+ // pastilla clara con el nombre en mayúsculas y el rol debajo, más chico (como en las series ilustradas)
258
+ const tam = c.tam ?? 40;
259
+ return (
260
+ <div style={{background: c.fondo ?? '#FFFFFF', color: c.colorTexto ?? '#141414', borderRadius: tam * 0.45, padding: `${tam * 0.24}px ${tam * 0.6}px`, textAlign: 'center', boxShadow: '0 6px 0 rgba(0,0,0,0.18), 0 10px 18px rgba(0,0,0,0.18)', border: `${Math.max(3, tam * 0.08)}px solid ${c.borde ?? '#141414'}`, whiteSpace: 'nowrap'}}>
261
+ <div style={{fontFamily: T.fuentes.texto, fontWeight: 900, fontSize: tam, lineHeight: 1.05, letterSpacing: 0.5}}>{String(c.texto ?? '').toUpperCase()}</div>
262
+ {c.sub ? <div style={{fontFamily: T.fuentes.texto, fontWeight: 700, fontSize: tam * 0.5, opacity: 0.6, marginTop: 2}}>{c.sub}</div> : null}
263
+ </div>
264
+ );
265
+ }
266
+
267
+ case 'accion': {
268
+ // "▶ Te despiertas": pastilla oscura con borde de acento; marca lo que hace el protagonista
269
+ const tam = c.tam ?? 36;
270
+ return (
271
+ <div style={{display: 'inline-flex', alignItems: 'center', gap: tam * 0.4, background: c.fondo ?? 'rgba(14,14,20,0.86)', color: c.colorTexto ?? '#FFFFFF', border: `${Math.max(2, tam * 0.08)}px solid ${color}`, borderRadius: 999, padding: `${tam * 0.26}px ${tam * 0.7}px`, fontFamily: T.fuentes.texto, fontWeight: 800, fontSize: tam, whiteSpace: 'nowrap', boxShadow: '0 8px 20px rgba(0,0,0,0.35)'}}>
272
+ <span style={{color, fontSize: tam * 0.7}}>▶</span>
273
+ {c.texto}
274
+ </div>
275
+ );
276
+ }
277
+
278
+ case 'cursor': {
279
+ const tam = c.tam ?? 64;
280
+ const [ax, ay] = c.a ?? [ox, oy];
281
+ const k = easeIO(clamp(t / (c.duracion ?? 0.7)));
282
+ const tc = c.clic != null ? ctx.t - cuando(c.clic, ctx, 0) : -1;
283
+ const pulso = tc >= 0 && tc < 0.5 ? tc / 0.5 : -1;
284
+ const hundir = tc >= 0 && tc < 0.16 ? 0.86 : 1;
285
+ return (
286
+ <div style={{position: 'relative', width: 0, height: 0, transform: `translate(${(ax - ox) * k}px, ${(ay - oy) * k}px)`}}>
287
+ {pulso >= 0 ? <div style={{position: 'absolute', left: -tam * (0.3 + pulso), top: -tam * (0.3 + pulso), width: tam * (0.6 + pulso * 2), height: tam * (0.6 + pulso * 2), borderRadius: '50%', border: `4px solid ${color}`, opacity: 1 - pulso}} /> : null}
288
+ <svg width={tam} height={tam} viewBox="0 0 24 24" style={{position: 'absolute', left: -tam * 0.14, top: -tam * 0.08, transform: `scale(${hundir})`, transformOrigin: '15% 10%', filter: 'drop-shadow(0 5px 6px rgba(0,0,0,0.4))'}}>
289
+ <path d="M4 2 L4 19 L8.6 15.2 L11.6 22 L14.6 20.7 L11.7 14 L18 14 Z" fill="#111" stroke="#fff" strokeWidth={1.6} strokeLinejoin="round" />
290
+ </svg>
291
+ </div>
292
+ );
293
+ }
294
+ }
295
+ return null;
296
+ };
@@ -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
+ };
@@ -25,7 +25,41 @@ const cargarGltf = (url: string) => {
25
25
  };
26
26
  const cargar = (url: string) => cargarGltf(url).then((g) => g.scene);
27
27
 
28
- const Glb: React.FC<{src: string}> = ({src}) => {
28
+ /**
29
+ * Caja del "cuerpo" del modelo: si casi todos los vértices se juntan en una zona pequeña y el resto son
30
+ * hilos sueltos (el cable de un control, la cuerda de un globo), encuadra esa zona y no la caja completa.
31
+ */
32
+ function cajaPrincipal(obj: THREE.Object3D): THREE.Box3 {
33
+ const completa = new THREE.Box3().setFromObject(obj);
34
+ const pts: number[] = [];
35
+ const v = new THREE.Vector3();
36
+ obj.updateMatrixWorld(true);
37
+ obj.traverse((o) => {
38
+ const m = o as THREE.Mesh;
39
+ const pos = m.isMesh ? (m.geometry?.attributes?.position as THREE.BufferAttribute | undefined) : undefined;
40
+ if (!pos) return;
41
+ const paso = Math.max(1, Math.floor(pos.count / 20000));
42
+ for (let i = 0; i < pos.count; i += paso) {
43
+ v.fromBufferAttribute(pos, i).applyMatrix4(m.matrixWorld);
44
+ pts.push(v.x, v.y, v.z);
45
+ }
46
+ });
47
+ if (pts.length < 300) return completa;
48
+ const N = 10;
49
+ const mn = completa.min.toArray();
50
+ const tam = completa.getSize(new THREE.Vector3()).toArray();
51
+ const celda = (i: number) => [0, 1, 2].map((k) => Math.min(N - 1, Math.floor(((pts[i + k] - mn[k]) / (tam[k] || 1)) * N))).join();
52
+ const cuenta = new Map<string, number>();
53
+ for (let i = 0; i < pts.length; i += 3) cuenta.set(celda(i), (cuenta.get(celda(i)) ?? 0) + 1);
54
+ const max = Math.max(...cuenta.values());
55
+ const caja = new THREE.Box3();
56
+ for (let i = 0; i < pts.length; i += 3) if ((cuenta.get(celda(i)) ?? 0) >= max * 0.2) caja.expandByPoint(v.set(pts[i], pts[i + 1], pts[i + 2]));
57
+ const lado = (b: THREE.Box3) => Math.max(...b.getSize(new THREE.Vector3()).toArray());
58
+ // solo si el cuerpo es mucho más chico que todo: en modelos normales la caja densa es casi la completa
59
+ return lado(caja) < 0.5 * lado(completa) ? caja.expandByScalar(lado(caja) * 0.08) : completa;
60
+ }
61
+
62
+ const Glb: React.FC<{src: string; levantar?: boolean}> = ({src, levantar}) => {
29
63
  const url = recurso(src);
30
64
  const [escena, setEscena] = useState<THREE.Group | null>(null);
31
65
  const [handle] = useState(() => delayRender(`Cargando modelo ${src}`));
@@ -34,20 +68,28 @@ const Glb: React.FC<{src: string}> = ({src}) => {
34
68
  .then((s) => {
35
69
  // centrado y escalado a ~2 unidades para que cualquier modelo quepa en el lienzo
36
70
  const copia = s.clone(true);
37
- const caja = new THREE.Box3().setFromObject(copia);
71
+ const caja = levantar ? cajaPrincipal(copia) : new THREE.Box3().setFromObject(copia);
38
72
  const tam = caja.getSize(new THREE.Vector3());
39
73
  const centro = caja.getCenter(new THREE.Vector3());
40
74
  const k = 2 / Math.max(tam.x, tam.y, tam.z, 0.0001);
41
75
  copia.position.sub(centro.multiplyScalar(k));
42
76
  copia.scale.setScalar(k);
43
- setEscena(copia);
77
+ // objetos planos acostados (un control, un libro, un plato): de frente se ven como una raya,
78
+ // así que se levantan hacia la cámara
79
+ const plano = levantar && tam.y < 0.3 * Math.max(tam.x, tam.z);
80
+ if (plano) {
81
+ const g = new THREE.Group();
82
+ g.add(copia);
83
+ g.rotation.x = 65 * RAD;
84
+ setEscena(g);
85
+ } else setEscena(copia);
44
86
  continueRender(handle);
45
87
  })
46
88
  .catch((e) => {
47
89
  continueRender(handle);
48
90
  throw new Error(`No pude cargar el modelo 3D ${src}: ${e.message}`);
49
91
  });
50
- }, [url, handle, src]);
92
+ }, [url, handle, src, levantar]);
51
93
  return escena ? <primitive object={escena} /> : null;
52
94
  };
53
95
 
@@ -137,7 +179,7 @@ export const Modelo3D: React.FC<{c: Record<string, any>; ctx: CtxEscena}> = ({c,
137
179
  <directionalLight position={[-4, 2, -3]} intensity={0.4} color="#9fc4ff" />
138
180
  <group scale={c.tamano ?? 1} rotation={[rx + gx * t, ry + gy * t, rz + gz * t]} position={[0, n > 1 ? -altoPila / 2 : 0, 0]}>
139
181
  {c.src ? (
140
- <Glb src={c.src} />
182
+ <Glb src={c.src} levantar={c.levantar} />
141
183
  ) : (OBJETOS_3D as readonly string[]).includes(c.forma) ? (
142
184
  <Objeto3D forma={c.forma} t={t} opciones={c} color={c.color} metal={c.metal} rugosidad={c.rugosidad} />
143
185
  ) : (
@@ -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}] : []),