morfeo 0.3.3 → 0.4.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.
- package/assets/fuentes/Silkscreen.ttf +0 -0
- package/bases/biografia/Mundo.tsx +35 -0
- package/bases/biografia/guion.mjs +34 -0
- package/bases/construiste/Mundo.tsx +273 -0
- package/bases/construiste/guion.mjs +84 -0
- package/bases/escalada/Mundo.tsx +45 -0
- package/bases/escalada/guion.mjs +24 -0
- package/bases/mito/Mundo.tsx +55 -0
- package/bases/mito/guion.mjs +29 -0
- package/bases/serie/guion.mjs +32 -0
- package/bin/morfeo.mjs +14 -6
- package/index.d.ts +51 -3
- package/lib/config.mjs +23 -4
- package/lib/estructura.mjs +9 -3
- package/lib/formulas.mjs +179 -0
- package/lib/guion.mjs +29 -3
- package/lib/nuevo-video.mjs +12 -3
- package/lib/palabras.mjs +2 -2
- package/package.json +1 -1
- package/python/musica.py +9 -1
- package/python/preparar.py +1 -1
- package/remotion/estilo.ts +2 -0
- package/remotion/kit/armar.tsx +245 -0
- package/remotion/kit/index.ts +1 -0
- package/remotion/motor/Video.tsx +214 -15
- package/remotion/motor/capas.tsx +2 -0
- package/remotion/motor/historias.tsx +440 -0
- package/remotion/motor/modos.tsx +120 -0
- package/remotion/motor/subtitulos.tsx +17 -2
- package/remotion/motor/tema.ts +10 -2
- package/remotion/motor/tierra-datos.ts +2 -0
- package/remotion/tipos.ts +1 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// Capas para series interactivas, historias y documental (medidas en los videos de referencia de "elige tú" y de biografías):
|
|
2
|
+
// bloqueo (pantalla de bloqueo con la hora gigante y notificaciones), llamada (entrante o en curso, con contador),
|
|
3
|
+
// recuento (lo que votaron en el capítulo anterior), capitulo (fin de capítulo con opciones A/B y horario),
|
|
4
|
+
// vigilancia (vista de cámara: REC, hora corriendo, esquinas y mira), cintillo (nombre y cargo de una persona),
|
|
5
|
+
// fecha (tarjeta de año: "1985 · SE CASARON"), calendario (hoja que cambia: "MESES 5") y dividida (pantalla A | B).
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import {clamp, easeIO, easeOut, lerp, resorte} from '../estilo';
|
|
8
|
+
import {Capas} from './capas';
|
|
9
|
+
import {TIERRA} from './tierra-datos';
|
|
10
|
+
import {azar, cuando, type CtxEscena} from './util';
|
|
11
|
+
|
|
12
|
+
type C = Record<string, any>;
|
|
13
|
+
const desde = (c: C, ctx: CtxEscena) => ctx.t - cuando(c.en, ctx, 0);
|
|
14
|
+
const MESES = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'];
|
|
15
|
+
const DIAS = ['domingo', 'lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado'];
|
|
16
|
+
|
|
17
|
+
/** Marco de teléfono sostenido (sin mano: solo el aparato). `ancho` en px. */
|
|
18
|
+
const Aparato: React.FC<{ancho: number; children: React.ReactNode; fondo?: string}> = ({ancho, children, fondo}) => {
|
|
19
|
+
const alto = ancho * 2.05;
|
|
20
|
+
return (
|
|
21
|
+
<div style={{position: 'relative', width: ancho, height: alto, borderRadius: ancho * 0.13, background: '#0D0D10', padding: ancho * 0.03, boxShadow: '0 30px 60px rgba(0,0,0,0.5), inset 0 0 0 3px #2A2A30'}}>
|
|
22
|
+
<div style={{position: 'relative', width: '100%', height: '100%', borderRadius: ancho * 0.105, overflow: 'hidden', background: fondo ?? 'linear-gradient(170deg, #4A2C6E 0%, #D96B4A 55%, #F2B45C 75%, #2B2340 100%)'}}>
|
|
23
|
+
{children}
|
|
24
|
+
<div style={{position: 'absolute', top: ancho * 0.035, left: '50%', transform: 'translateX(-50%)', width: ancho * 0.3, height: ancho * 0.075, borderRadius: 99, background: '#000'}} />
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Pantalla de bloqueo: fecha, hora gigante (y año abajo si lo das: los videos de viajes en el tiempo lo usan) y
|
|
32
|
+
* notificaciones que entran en su palabra. `hora: '18:40'`, `fecha: 'lunes, 10 de noviembre'`, `anio: 2031`,
|
|
33
|
+
* `notificaciones: [{app: 'Mensajes', titulo: 'Tú', texto: 'No llames a casa.', en: 'vibra'}]`, `fondo` (CSS).
|
|
34
|
+
*/
|
|
35
|
+
export const Bloqueo: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
36
|
+
const ancho = c.ancho ?? 520;
|
|
37
|
+
const notifs: C[] = c.notificaciones ?? [];
|
|
38
|
+
// el año puede "saltar" de uno a otro: anio: [2026, 2031] con anioEn
|
|
39
|
+
const anios = Array.isArray(c.anio) ? c.anio : c.anio != null ? [c.anio] : [];
|
|
40
|
+
const cambio = anios.length > 1 ? clamp((ctx.t - cuando(c.anioEn, ctx, 0.6)) / 0.35) : 1;
|
|
41
|
+
const anio = anios.length > 1 ? (cambio < 0.5 ? anios[0] : anios[anios.length - 1]) : anios[0];
|
|
42
|
+
const sacudida = anios.length > 1 && cambio > 0 && cambio < 1 ? Math.sin(cambio * 40) * 6 : 0;
|
|
43
|
+
return (
|
|
44
|
+
<Aparato ancho={ancho} fondo={c.fondo}>
|
|
45
|
+
<div style={{position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: ancho * 0.2, color: '#FFFFFF', fontFamily: 'Montserrat, sans-serif', textShadow: '0 2px 12px rgba(0,0,0,0.35)'}}>
|
|
46
|
+
<div style={{fontSize: ancho * 0.05, fontWeight: 600, opacity: 0.9}}>{c.fecha ?? 'lunes, 10 de noviembre'}</div>
|
|
47
|
+
<div style={{fontSize: ancho * 0.3, fontWeight: 800, letterSpacing: -ancho * 0.01, lineHeight: 1}}>{c.hora ?? '18:40'}</div>
|
|
48
|
+
{anio != null ? (
|
|
49
|
+
<div style={{marginTop: ancho * 0.02, fontSize: ancho * 0.085, fontWeight: 900, letterSpacing: ancho * 0.02, padding: `${ancho * 0.01}px ${ancho * 0.05}px`, borderRadius: ancho * 0.03, background: cambio >= 1 && anios.length > 1 ? 'rgba(229,72,77,0.85)' : 'rgba(0,0,0,0.25)', transform: `translateX(${sacudida}px)`}}>
|
|
50
|
+
{anio}
|
|
51
|
+
</div>
|
|
52
|
+
) : null}
|
|
53
|
+
<div style={{marginTop: 'auto', marginBottom: ancho * 0.12, width: '88%', display: 'flex', flexDirection: 'column', gap: ancho * 0.025}}>
|
|
54
|
+
{notifs.map((n, i) => {
|
|
55
|
+
const k = clamp((ctx.t - cuando(n.en, ctx, 0.4 + i * 0.5)) / 0.35);
|
|
56
|
+
if (k <= 0) return null;
|
|
57
|
+
return (
|
|
58
|
+
<div key={i} style={{opacity: k, transform: `translateY(${(1 - easeOut(k)) * 30}px) scale(${lerp(0.94, 1, easeOut(k))})`, background: 'rgba(245,245,250,0.82)', color: '#111', borderRadius: ancho * 0.05, padding: `${ancho * 0.03}px ${ancho * 0.04}px`, textShadow: 'none'}}>
|
|
59
|
+
<div style={{fontSize: ancho * 0.034, fontWeight: 700, opacity: 0.6, textTransform: 'uppercase', letterSpacing: 1}}>{n.app ?? 'Mensajes'}</div>
|
|
60
|
+
<div style={{fontSize: ancho * 0.045, fontWeight: 800}}>{n.titulo}</div>
|
|
61
|
+
<div style={{fontSize: ancho * 0.045, fontWeight: 500, lineHeight: 1.25}}>{n.texto}</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
})}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</Aparato>
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Llamada en el teléfono. `estado: 'entrante'` (botones colgar/contestar que laten) o `'curso'` (contador que corre
|
|
73
|
+
* desde `desde` segundos y onda de voz). `'terminada'` muestra "Llamada finalizada" y la duración fija. `nombre`, `numero`.
|
|
74
|
+
*/
|
|
75
|
+
export const Llamada: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
76
|
+
const ancho = c.ancho ?? 520;
|
|
77
|
+
const t = Math.max(0, desde(c, ctx));
|
|
78
|
+
const estado = c.estado ?? 'entrante';
|
|
79
|
+
const seg = estado === 'terminada' ? c.duracion ?? 33 : (c.desde ?? 0) + Math.floor(t);
|
|
80
|
+
const mmss = `${Math.floor(seg / 60)}:${String(seg % 60).padStart(2, '0')}`;
|
|
81
|
+
const late = 1 + Math.sin(t * 7) * 0.06;
|
|
82
|
+
return (
|
|
83
|
+
<Aparato ancho={ancho} fondo={c.fondo ?? 'linear-gradient(180deg, #1C2238 0%, #0B0E1A 100%)'}>
|
|
84
|
+
<div style={{position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: ancho * 0.22, color: '#FFFFFF', fontFamily: 'Montserrat, sans-serif'}}>
|
|
85
|
+
{estado === 'entrante' && c.aviso !== false ? <div style={{background: '#E5484D', fontWeight: 800, fontSize: ancho * 0.04, padding: `${ancho * 0.008}px ${ancho * 0.03}px`, borderRadius: 6, marginBottom: ancho * 0.03, letterSpacing: 1}}>{c.aviso ?? 'LLAMADA ENTRANTE'}</div> : null}
|
|
86
|
+
<div style={{width: ancho * 0.26, height: ancho * 0.26, borderRadius: '50%', background: '#8E93A6', display: 'flex', alignItems: 'flex-end', justifyContent: 'center', overflow: 'hidden'}}>
|
|
87
|
+
<div style={{width: ancho * 0.12, height: ancho * 0.12, borderRadius: '50%', background: '#D9DCE5', marginBottom: ancho * 0.015, boxShadow: `0 ${ancho * 0.1}px 0 ${ancho * 0.05}px #D9DCE5`}} />
|
|
88
|
+
</div>
|
|
89
|
+
<div style={{fontSize: ancho * 0.1, fontWeight: 800, marginTop: ancho * 0.04}}>{c.nombre ?? 'Desconocido'}</div>
|
|
90
|
+
<div style={{fontSize: ancho * 0.042, opacity: 0.7}}>{estado === 'terminada' ? 'Llamada finalizada' : estado === 'curso' ? mmss : c.numero ?? 'móvil'}</div>
|
|
91
|
+
{estado === 'terminada' ? <div style={{fontSize: ancho * 0.2, fontWeight: 800, marginTop: ancho * 0.08}}>{mmss}</div> : null}
|
|
92
|
+
{estado === 'curso' ? (
|
|
93
|
+
<div style={{display: 'flex', alignItems: 'center', gap: 4, height: ancho * 0.12, marginTop: ancho * 0.08}}>
|
|
94
|
+
{Array.from({length: 22}, (_, i) => (
|
|
95
|
+
<div key={i} style={{width: 4, borderRadius: 2, background: '#FFFFFF', height: `${20 + 80 * Math.abs(Math.sin(t * 6 + i * 0.9) * azar(`onda${i}`))}%`, opacity: 0.85}} />
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
) : null}
|
|
99
|
+
<div style={{marginTop: 'auto', marginBottom: ancho * 0.16, display: 'flex', gap: ancho * 0.22}}>
|
|
100
|
+
{(estado === 'entrante' ? ['#E5484D', '#30A46C'] : estado === 'curso' ? ['#E5484D'] : []).map((col, i) => (
|
|
101
|
+
<div key={i} style={{width: ancho * 0.17, height: ancho * 0.17, borderRadius: '50%', background: col, transform: `scale(${estado === 'entrante' && i === 1 ? late : 1})`, display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: `0 0 ${estado === 'entrante' && i === 1 ? 30 : 0}px ${col}`}}>
|
|
102
|
+
<div style={{width: ancho * 0.08, height: ancho * 0.03, borderRadius: 99, background: '#FFFFFF', transform: i === 0 ? 'rotate(0deg)' : 'rotate(-35deg)'}} />
|
|
103
|
+
</div>
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</Aparato>
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Recuento de la votación del capítulo anterior: "VOTACIÓN · CAPÍTULO 1 / LA MAYORÍA DE USTEDES VOTÓ" y barras
|
|
113
|
+
* que se llenan; la ganadora en color. `opciones: [{texto: 'SÍ', sub: 'no tomas el metro', valor: 90}]`.
|
|
114
|
+
*/
|
|
115
|
+
export const Recuento: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
116
|
+
const t = desde(c, ctx);
|
|
117
|
+
const ops: C[] = c.opciones ?? [];
|
|
118
|
+
const max = Math.max(...ops.map((o) => o.valor ?? 0));
|
|
119
|
+
const color = c.color ?? '#E5484D';
|
|
120
|
+
return (
|
|
121
|
+
<div style={{width: c.ancho ?? 820, fontFamily: 'Montserrat, sans-serif', color: '#FFFFFF'}}>
|
|
122
|
+
<div style={{textAlign: 'center', fontSize: 26, letterSpacing: 6, opacity: 0.6, fontWeight: 600}}>{c.cabecera ?? 'VOTACIÓN · CAPÍTULO 1'}</div>
|
|
123
|
+
<div style={{textAlign: 'center', fontSize: 62, fontWeight: 900, lineHeight: 1.08, margin: '18px 0 44px'}}>{c.titulo ?? 'LA MAYORÍA DE USTEDES VOTÓ'}</div>
|
|
124
|
+
{ops.map((o, i) => {
|
|
125
|
+
const k = easeOut(clamp((t - 0.35 - i * 0.18) / 1));
|
|
126
|
+
const gana = (o.valor ?? 0) === max;
|
|
127
|
+
return (
|
|
128
|
+
<div key={i} style={{marginBottom: 38, opacity: gana ? 1 : 0.55}}>
|
|
129
|
+
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'baseline'}}>
|
|
130
|
+
<span style={{fontSize: 54, fontWeight: 900}}>{o.texto}</span>
|
|
131
|
+
<span style={{fontSize: 46, fontWeight: 900, color: gana ? color : '#FFFFFF'}}>{Math.round((o.valor ?? 0) * k)}%</span>
|
|
132
|
+
</div>
|
|
133
|
+
<div style={{height: 14, borderRadius: 99, background: 'rgba(255,255,255,0.14)', marginTop: 10}}>
|
|
134
|
+
<div style={{width: `${(o.valor ?? 0) * k}%`, height: '100%', borderRadius: 99, background: gana ? '#FFFFFF' : 'rgba(255,255,255,0.5)'}} />
|
|
135
|
+
</div>
|
|
136
|
+
{o.sub ? <div style={{fontSize: 26, opacity: 0.6, marginTop: 8}}>{o.sub}</div> : null}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
})}
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Fin de capítulo: nombre de la serie, "CAPÍTULO 2", la pregunta y dos (o más) opciones como botones, "comenta tu
|
|
146
|
+
* opción · la más votada gana" y el botón de seguir con el horario del siguiente.
|
|
147
|
+
* `serie`, `capitulo`, `pregunta`, `opciones: [{texto: 'ABRIR', sub: 'le abres la puerta'}]`, `siguiente: 'CAPÍTULO 3'`, `horario`.
|
|
148
|
+
*/
|
|
149
|
+
export const Capitulo: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
150
|
+
const t = desde(c, ctx);
|
|
151
|
+
const ops: C[] = c.opciones ?? [{texto: 'A'}, {texto: 'B'}];
|
|
152
|
+
const color = c.color ?? '#E5484D';
|
|
153
|
+
const pasa = (d: number) => easeOut(clamp((t - d) / 0.4));
|
|
154
|
+
return (
|
|
155
|
+
<div style={{width: c.ancho ?? 860, fontFamily: 'Montserrat, sans-serif', color: '#FFFFFF', textAlign: 'center'}}>
|
|
156
|
+
<div style={{opacity: pasa(0), fontSize: 30, fontWeight: 900, letterSpacing: 6, color}}>{c.serie ?? 'MI SERIE'}</div>
|
|
157
|
+
<div style={{opacity: pasa(0.1) * 0.6, fontSize: 22, letterSpacing: 8, marginTop: 6}}>{(c.capitulo ?? 'CAPÍTULO 1').toUpperCase()}</div>
|
|
158
|
+
<div style={{opacity: pasa(0.25), transform: `translateY(${(1 - pasa(0.25)) * 20}px)`, fontSize: 74, fontWeight: 900, lineHeight: 1.05, margin: '40px 0 46px'}}>{c.pregunta ?? '¿QUÉ HACES?'}</div>
|
|
159
|
+
<div style={{display: 'flex', gap: 28, justifyContent: 'center'}}>
|
|
160
|
+
{ops.map((o, i) => (
|
|
161
|
+
<div key={i} style={{opacity: pasa(0.5 + i * 0.12), transform: `scale(${resorte(pasa(0.5 + i * 0.12), 0.3)})`, flex: 1, maxWidth: 380}}>
|
|
162
|
+
<div style={{border: `4px solid ${i === 0 ? '#FFFFFF' : color}`, color: i === 0 ? '#FFFFFF' : color, borderRadius: 99, padding: '18px 10px', fontSize: 40, fontWeight: 900}}>{o.texto}</div>
|
|
163
|
+
{o.sub ? <div style={{fontSize: 24, opacity: 0.6, marginTop: 10}}>{o.sub}</div> : null}
|
|
164
|
+
</div>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
<div style={{opacity: pasa(0.9) * 0.6, fontSize: 22, letterSpacing: 3, marginTop: 40}}>{c.pie ?? 'COMENTA TU OPCIÓN · LA MÁS VOTADA GANA'}</div>
|
|
168
|
+
<div style={{opacity: pasa(1.1), display: 'inline-block', marginTop: 34, background: color, borderRadius: 99, padding: '18px 44px', fontSize: 32, fontWeight: 900, boxShadow: `0 0 ${20 + 14 * Math.sin(t * 4)}px ${color}`}}>
|
|
169
|
+
SÍGUEME PARA EL {(c.siguiente ?? 'SIGUIENTE CAPÍTULO').toUpperCase()}
|
|
170
|
+
</div>
|
|
171
|
+
{c.horario ? <div style={{opacity: pasa(1.2) * 0.65, fontSize: 24, marginTop: 14}}>{c.horario}</div> : null}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Vista de cámara de vigilancia sobre toda la pantalla: REC que parpadea, fecha y hora corriendo, esquinas, mira al
|
|
178
|
+
* centro y zoom. `hora: '23:47:14'`, `fecha`, `etiqueta: 'CAM 03'`, `mira: false` la quita. Va encima del mundo.
|
|
179
|
+
*/
|
|
180
|
+
export const Vigilancia: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
181
|
+
const t = Math.max(0, desde(c, ctx));
|
|
182
|
+
const [h, m, s] = String(c.hora ?? '23:47:14').split(':').map(Number);
|
|
183
|
+
const total = (h || 0) * 3600 + (m || 0) * 60 + (s || 0) + Math.floor(t);
|
|
184
|
+
const hh = (n: number) => String(n).padStart(2, '0');
|
|
185
|
+
const reloj = `${hh(Math.floor(total / 3600) % 24)}:${hh(Math.floor(total / 60) % 60)}:${hh(total % 60)}`;
|
|
186
|
+
const color = c.color ?? '#FFFFFF';
|
|
187
|
+
const esquina = (st: React.CSSProperties) => <div style={{position: 'absolute', width: 90, height: 90, borderColor: color, borderStyle: 'solid', borderWidth: 0, ...st}} />;
|
|
188
|
+
return (
|
|
189
|
+
<div style={{position: 'relative', width: 1080, height: 1920, fontFamily: 'JetBrains Mono, monospace', color, pointerEvents: 'none'}}>
|
|
190
|
+
<div style={{position: 'absolute', inset: 0, background: 'repeating-linear-gradient(0deg, rgba(255,255,255,0.05) 0 2px, transparent 2px 5px)', mixBlendMode: 'overlay'}} />
|
|
191
|
+
<div style={{position: 'absolute', inset: 0, background: 'radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%)'}} />
|
|
192
|
+
{esquina({left: 70, top: 260, borderLeftWidth: 6, borderTopWidth: 6})}
|
|
193
|
+
{esquina({right: 70, top: 260, borderRightWidth: 6, borderTopWidth: 6})}
|
|
194
|
+
{esquina({left: 70, bottom: 260, borderLeftWidth: 6, borderBottomWidth: 6})}
|
|
195
|
+
{esquina({right: 70, bottom: 260, borderRightWidth: 6, borderBottomWidth: 6})}
|
|
196
|
+
<div style={{position: 'absolute', left: 100, top: 290, display: 'flex', alignItems: 'center', gap: 14, fontSize: 34, fontWeight: 800}}>
|
|
197
|
+
<span style={{width: 22, height: 22, borderRadius: '50%', background: '#FF3B30', opacity: Math.floor(t * 2) % 2 ? 0.25 : 1}} /> REC
|
|
198
|
+
</div>
|
|
199
|
+
<div style={{position: 'absolute', right: 100, top: 290, fontSize: 30, fontWeight: 700, textAlign: 'right'}}>
|
|
200
|
+
{c.etiqueta ?? 'CAM 03'}
|
|
201
|
+
<div style={{fontSize: 26, opacity: 0.8}}>{c.fecha ?? ''}</div>
|
|
202
|
+
</div>
|
|
203
|
+
<div style={{position: 'absolute', right: 100, bottom: 290, fontSize: 36, fontWeight: 800}}>{reloj}</div>
|
|
204
|
+
<div style={{position: 'absolute', left: 100, bottom: 290, fontSize: 24, opacity: 0.8}}>ZOOM {(c.zoom ?? 3.8).toFixed(1)}x</div>
|
|
205
|
+
{c.mira !== false ? (
|
|
206
|
+
<div style={{position: 'absolute', left: 540 + (c.miraX ?? 0), top: 960 + (c.miraY ?? 0), transform: 'translate(-50%, -50%)'}}>
|
|
207
|
+
<div style={{width: 120, height: 120, borderRadius: '50%', border: `3px solid ${color}`, opacity: 0.9}} />
|
|
208
|
+
<div style={{position: 'absolute', left: -30, top: 59, width: 180, height: 3, background: color}} />
|
|
209
|
+
<div style={{position: 'absolute', left: 59, top: -30, width: 3, height: 180, background: color}} />
|
|
210
|
+
</div>
|
|
211
|
+
) : null}
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Cintillo de persona (documental): nombre en grande y cargo abajo, con barra de color a la izquierda; entra
|
|
218
|
+
* deslizándose. `nombre`, `cargo`, `color`. Pensado abajo a la izquierda (x ~ 300, y ~ 1560).
|
|
219
|
+
*/
|
|
220
|
+
export const Cintillo: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
221
|
+
const k = easeIO(clamp(desde(c, ctx) / 0.45));
|
|
222
|
+
const color = c.color ?? ctx.tema.colores.acento;
|
|
223
|
+
return (
|
|
224
|
+
<div style={{display: 'flex', alignItems: 'stretch', fontFamily: 'Montserrat, sans-serif', clipPath: `inset(0 ${(1 - k) * 100}% 0 0)`}}>
|
|
225
|
+
<div style={{width: 12, background: color}} />
|
|
226
|
+
<div style={{background: 'rgba(12,12,16,0.88)', padding: '14px 28px 16px 22px'}}>
|
|
227
|
+
<div style={{color: '#FFFFFF', fontWeight: 900, fontSize: c.tam ?? 56, lineHeight: 1.05}}>{c.nombre}</div>
|
|
228
|
+
{c.cargo ? <div style={{color: 'rgba(255,255,255,0.7)', fontWeight: 600, fontSize: (c.tam ?? 56) * 0.48, marginTop: 6, textTransform: 'uppercase', letterSpacing: 2}}>{c.cargo}</div> : null}
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/** Tarjeta de año: el año en grande (dorado por defecto) y qué pasó abajo: "1985 · SE CASARON". El año cuenta hacia su valor. */
|
|
235
|
+
export const Fecha: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
236
|
+
const t = desde(c, ctx);
|
|
237
|
+
const k = easeOut(clamp(t / 0.7));
|
|
238
|
+
const fin = Number(c.anio ?? c.valor ?? 2000);
|
|
239
|
+
const ini = c.de != null ? Number(c.de) : fin;
|
|
240
|
+
const anio = Math.round(lerp(ini, fin, k));
|
|
241
|
+
const color = c.color ?? '#E8C66A';
|
|
242
|
+
return (
|
|
243
|
+
<div style={{transform: `scale(${resorte(clamp(t / 0.4), 0.3)})`, background: 'rgba(14,12,10,0.86)', border: `4px solid ${color}`, borderRadius: 14, padding: '10px 34px 14px', textAlign: 'center', fontFamily: 'Montserrat, sans-serif', boxShadow: '0 16px 34px rgba(0,0,0,0.45)'}}>
|
|
244
|
+
<div style={{color, fontWeight: 900, fontSize: c.tam ?? 96, lineHeight: 1}}>{c.texto ?? anio}</div>
|
|
245
|
+
{c.pie ? <div style={{color: '#FFFFFF', fontWeight: 800, fontSize: (c.tam ?? 96) * 0.26, letterSpacing: 2, textTransform: 'uppercase', marginTop: 4}}>{c.pie}</div> : null}
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Calendario de hoja: encabezado de color con el título ("MESES", "DÍA", un mes) y el número grande, que cambia de
|
|
252
|
+
* `de` a `a` volteando la hoja. `titulo`, `de`, `a`, `color`.
|
|
253
|
+
*/
|
|
254
|
+
export const Calendario: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
255
|
+
const t = desde(c, ctx);
|
|
256
|
+
const de = Number(c.de ?? c.a ?? c.valor ?? 1);
|
|
257
|
+
const a = Number(c.a ?? c.valor ?? de);
|
|
258
|
+
const pasos = Math.abs(a - de);
|
|
259
|
+
const dur = c.duracion ?? Math.min(1.6, 0.25 * pasos + 0.3);
|
|
260
|
+
const k = clamp((t - 0.3) / dur);
|
|
261
|
+
const v = pasos ? Math.round(lerp(de, a, easeOut(k))) : a;
|
|
262
|
+
// la hoja se voltea cada vez que cambia el número
|
|
263
|
+
const frac = pasos ? ((easeOut(k) * pasos) % 1) : 1;
|
|
264
|
+
const tam = c.tam ?? 220;
|
|
265
|
+
const color = c.color ?? '#E5484D';
|
|
266
|
+
return (
|
|
267
|
+
<div style={{width: tam, borderRadius: tam * 0.08, overflow: 'hidden', background: '#FFFFFF', boxShadow: '0 16px 34px rgba(0,0,0,0.35)', fontFamily: 'Montserrat, sans-serif', textAlign: 'center', transform: `scale(${resorte(clamp(t / 0.35), 0.3)})`}}>
|
|
268
|
+
<div style={{background: color, color: '#FFFFFF', fontWeight: 900, fontSize: tam * 0.13, padding: `${tam * 0.04}px 0`, letterSpacing: 2}}>{(c.titulo ?? 'DÍA').toUpperCase()}</div>
|
|
269
|
+
<div style={{fontWeight: 900, fontSize: tam * 0.46, color: '#1B1B1F', lineHeight: 1.25, transform: `perspective(400px) rotateX(${k < 1 && pasos ? (1 - frac) * 50 : 0}deg)`}}>{v}</div>
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Pantalla dividida en dos (lado a lado o arriba/abajo): cada mitad tiene su etiqueta y sus capas, con una línea
|
|
276
|
+
* al centro. `izq: {etiqueta: 'CINTA', capas: [...]}`, `der: {...}`, `vertical: true` para arriba/abajo, `fondo`.
|
|
277
|
+
* Las capas de cada mitad usan coordenadas de su mitad (540×1920 o 1080×960).
|
|
278
|
+
*/
|
|
279
|
+
export const Dividida: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
280
|
+
const k = easeIO(clamp(desde(c, ctx) / 0.5));
|
|
281
|
+
const vertical = !!c.vertical;
|
|
282
|
+
const W2 = vertical ? 1080 : 540;
|
|
283
|
+
const H2 = vertical ? 960 : 1920;
|
|
284
|
+
const mitad = (m: C | undefined, i: number) => (
|
|
285
|
+
<div key={i} style={{position: 'absolute', left: vertical ? 0 : i * 540, top: vertical ? i * 960 : 0, width: W2, height: H2, overflow: 'hidden', background: m?.fondo ?? c.fondo ?? (i === 0 ? '#1A1C24' : '#22252F'), clipPath: vertical ? `inset(0 ${(1 - k) * 100}% 0 0)` : `inset(${(1 - k) * 100}% 0 0 0)`}}>
|
|
286
|
+
<div style={{position: 'absolute', left: 0, top: 0, width: 1080, height: 1920, transform: vertical ? `translateY(${-480}px)` : `translateX(${-270}px)`}}>
|
|
287
|
+
<Capas capas={(m?.capas ?? []) as any} ctx={ctx} />
|
|
288
|
+
</div>
|
|
289
|
+
{m?.etiqueta ? (
|
|
290
|
+
<div style={{position: 'absolute', left: '50%', top: vertical ? 40 : (c.etiquetaY ?? 430), transform: 'translateX(-50%)', background: m.color ?? '#1B1B1F', color: '#FFFFFF', fontFamily: 'Montserrat, sans-serif', fontWeight: 900, fontSize: 34, letterSpacing: 3, padding: '8px 22px', borderRadius: 10}}>{m.etiqueta}</div>
|
|
291
|
+
) : null}
|
|
292
|
+
</div>
|
|
293
|
+
);
|
|
294
|
+
return (
|
|
295
|
+
<div style={{position: 'relative', width: 1080, height: 1920}}>
|
|
296
|
+
{mitad(c.izq ?? c.arriba, 0)}
|
|
297
|
+
{mitad(c.der ?? c.abajo, 1)}
|
|
298
|
+
<div style={{position: 'absolute', background: c.linea ?? '#FFFFFF', ...(vertical ? {left: 0, top: 957, width: 1080 * k, height: 6} : {left: 537, top: 0, width: 6, height: 1920 * k})}} />
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Globo terráqueo que gira hasta dejar `lat`/`lon` al frente y clava un pin con su etiqueta ("Taiwán · 1963").
|
|
305
|
+
* Proyección ortográfica de los contornos de Natural Earth (dominio público). `tam`, `color` (del pin), `girar: false`
|
|
306
|
+
* lo deja quieto. Pensado como plano de apertura de una biografía o un "¿dónde pasó?".
|
|
307
|
+
*/
|
|
308
|
+
export const Tierra: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
309
|
+
const t = desde(c, ctx);
|
|
310
|
+
const tam = c.tam ?? 760;
|
|
311
|
+
const R = tam / 2 - 30;
|
|
312
|
+
const k = c.girar === false ? 1 : easeIO(clamp(t / (c.duracion ?? 1.8)));
|
|
313
|
+
const lon0 = Number(c.lon) + lerp(-110, 0, k) + (c.girar === false ? 0 : t * 1.5 * (1 - k));
|
|
314
|
+
const lat0 = lerp(0, Math.max(-50, Math.min(50, Number(c.lat))) * 0.7, k);
|
|
315
|
+
const rad = Math.PI / 180;
|
|
316
|
+
const [sl0, cl0] = [Math.sin(lat0 * rad), Math.cos(lat0 * rad)];
|
|
317
|
+
const proy = (lon: number, lat: number): [number, number, number] => {
|
|
318
|
+
const dl = (lon - lon0) * rad;
|
|
319
|
+
const [sl, cl] = [Math.sin(lat * rad), Math.cos(lat * rad)];
|
|
320
|
+
const x = cl * Math.sin(dl);
|
|
321
|
+
const y = cl0 * sl - sl0 * cl * Math.cos(dl);
|
|
322
|
+
const z = sl0 * sl + cl0 * cl * Math.cos(dl); // > 0: cara visible
|
|
323
|
+
return [x, y, z];
|
|
324
|
+
};
|
|
325
|
+
const cx = tam / 2;
|
|
326
|
+
// Recorte en el horizonte: cada polígono se camina arista por arista; donde cruza al otro lado se corta en el
|
|
327
|
+
// borde del disco y el tramo de atrás se reemplaza por el arco del borde. La Antártida solo si se mira hacia el sur.
|
|
328
|
+
const borde = (a: [number, number, number], b: [number, number, number]): [number, number] => {
|
|
329
|
+
const f = a[2] / (a[2] - b[2]);
|
|
330
|
+
const x = a[0] + (b[0] - a[0]) * f;
|
|
331
|
+
const y = a[1] + (b[1] - a[1]) * f;
|
|
332
|
+
const n = Math.hypot(x, y) || 1;
|
|
333
|
+
return [x / n, y / n];
|
|
334
|
+
};
|
|
335
|
+
const P = (x: number, y: number) => `${(cx + x * R).toFixed(1)} ${(cx - y * R).toFixed(1)}`;
|
|
336
|
+
const caminos = TIERRA.map((poly) => {
|
|
337
|
+
if (lat0 > -30 && poly.every((v, i) => i % 2 === 0 || v < -60)) return '';
|
|
338
|
+
const pts: [number, number, number][] = [];
|
|
339
|
+
for (let i = 0; i < poly.length; i += 2) pts.push(proy(poly[i], poly[i + 1]));
|
|
340
|
+
if (!pts.some((p) => p[2] > 0)) return '';
|
|
341
|
+
// empezar en un punto visible para que el camino abra bien
|
|
342
|
+
const i0 = pts.findIndex((p) => p[2] > 0);
|
|
343
|
+
const ord = [...pts.slice(i0), ...pts.slice(0, i0)];
|
|
344
|
+
let d = `M${P(ord[0][0], ord[0][1])}`;
|
|
345
|
+
let salida: [number, number] | null = null;
|
|
346
|
+
for (let i = 1; i <= ord.length; i++) {
|
|
347
|
+
const a = ord[i - 1];
|
|
348
|
+
const b = ord[i % ord.length];
|
|
349
|
+
if (a[2] > 0 && b[2] > 0) d += `L${P(b[0], b[1])}`;
|
|
350
|
+
else if (a[2] > 0 && b[2] <= 0) {
|
|
351
|
+
salida = borde(a, b);
|
|
352
|
+
d += `L${P(salida[0], salida[1])}`;
|
|
353
|
+
} else if (a[2] <= 0 && b[2] > 0) {
|
|
354
|
+
const entrada = borde(a, b);
|
|
355
|
+
if (salida) {
|
|
356
|
+
// arco por el borde: el sentido corto entre la salida y la entrada
|
|
357
|
+
const cruz = salida[0] * entrada[1] - salida[1] * entrada[0];
|
|
358
|
+
d += `A${R} ${R} 0 0 ${cruz > 0 ? 0 : 1} ${P(entrada[0], entrada[1])}`;
|
|
359
|
+
} else d += `L${P(entrada[0], entrada[1])}`;
|
|
360
|
+
d += `L${P(b[0], b[1])}`;
|
|
361
|
+
salida = null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return d + 'Z';
|
|
365
|
+
}).filter(Boolean);
|
|
366
|
+
const [px, py, pz] = proy(Number(c.lon), Number(c.lat));
|
|
367
|
+
const pin = clamp((t - (c.girar === false ? 0.2 : (c.duracion ?? 1.8) * 0.85)) / 0.35);
|
|
368
|
+
const X = cx + px * R;
|
|
369
|
+
const Y = cx - py * R;
|
|
370
|
+
const color = c.color ?? '#E5484D';
|
|
371
|
+
return (
|
|
372
|
+
<div style={{position: 'relative', width: tam, height: tam}}>
|
|
373
|
+
<svg width={tam} height={tam} style={{overflow: 'visible'}}>
|
|
374
|
+
<defs>
|
|
375
|
+
<radialGradient id="tierra-mar" cx="38%" cy="32%" r="75%">
|
|
376
|
+
<stop offset="0" stopColor="#3C8DE0" />
|
|
377
|
+
<stop offset="0.65" stopColor="#1C4FA3" />
|
|
378
|
+
<stop offset="1" stopColor="#0B2457" />
|
|
379
|
+
</radialGradient>
|
|
380
|
+
<radialGradient id="tierra-luz" cx="35%" cy="30%" r="80%">
|
|
381
|
+
<stop offset="0" stopColor="rgba(255,255,255,0.28)" />
|
|
382
|
+
<stop offset="0.5" stopColor="rgba(255,255,255,0)" />
|
|
383
|
+
<stop offset="1" stopColor="rgba(0,0,20,0.55)" />
|
|
384
|
+
</radialGradient>
|
|
385
|
+
<clipPath id="tierra-disco">
|
|
386
|
+
<circle cx={cx} cy={cx} r={R} />
|
|
387
|
+
</clipPath>
|
|
388
|
+
</defs>
|
|
389
|
+
<circle cx={cx} cy={cx} r={R + 18} fill="none" stroke="rgba(120,190,255,0.25)" strokeWidth={22} style={{filter: 'blur(8px)'}} />
|
|
390
|
+
<circle cx={cx} cy={cx} r={R} fill="url(#tierra-mar)" />
|
|
391
|
+
<g clipPath="url(#tierra-disco)">
|
|
392
|
+
{caminos.map((d, i) => (
|
|
393
|
+
<path key={i} d={d} fill="#6FBF73" stroke="#4E9A55" strokeWidth={1.2} strokeLinejoin="round" />
|
|
394
|
+
))}
|
|
395
|
+
</g>
|
|
396
|
+
<circle cx={cx} cy={cx} r={R} fill="url(#tierra-luz)" />
|
|
397
|
+
{pin > 0 && pz > 0 ? (
|
|
398
|
+
<g transform={`translate(${X} ${Y}) scale(${resorte(pin, 0.3)})`}>
|
|
399
|
+
<circle r={26 + 30 * ((t * 1.2) % 1)} fill="none" stroke={color} strokeWidth={4} opacity={1 - ((t * 1.2) % 1)} />
|
|
400
|
+
<path d="M0 0 C -16 -22 -22 -34 -22 -46 A 22 22 0 1 1 22 -46 C 22 -34 16 -22 0 0 Z" fill={color} stroke="#FFFFFF" strokeWidth={4} />
|
|
401
|
+
<circle cy={-46} r={8} fill="#FFFFFF" />
|
|
402
|
+
</g>
|
|
403
|
+
) : null}
|
|
404
|
+
</svg>
|
|
405
|
+
{c.etiqueta && pin > 0 && pz > 0 ? (
|
|
406
|
+
<div style={{position: 'absolute', left: X, top: Y + 18, transform: `translateX(-50%) scale(${resorte(clamp((t - (c.duracion ?? 1.8) * 0.85 - 0.15) / 0.35), 0.3)})`, background: color, color: '#FFFFFF', fontFamily: 'Montserrat, sans-serif', fontWeight: 900, fontSize: tam * 0.045, padding: '6px 18px', borderRadius: 10, whiteSpace: 'nowrap', boxShadow: '0 8px 18px rgba(0,0,0,0.35)'}}>
|
|
407
|
+
{c.etiqueta}
|
|
408
|
+
</div>
|
|
409
|
+
) : null}
|
|
410
|
+
</div>
|
|
411
|
+
);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
export const HISTORIAS = ['tierra', 'bloqueo', 'llamada', 'recuento', 'capitulo', 'vigilancia', 'cintillo', 'fecha', 'calendario', 'dividida'];
|
|
415
|
+
export const Historias: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
416
|
+
switch (c.tipo) {
|
|
417
|
+
case 'tierra':
|
|
418
|
+
return <Tierra c={c} ctx={ctx} />;
|
|
419
|
+
case 'bloqueo':
|
|
420
|
+
return <Bloqueo c={c} ctx={ctx} />;
|
|
421
|
+
case 'llamada':
|
|
422
|
+
return <Llamada c={c} ctx={ctx} />;
|
|
423
|
+
case 'recuento':
|
|
424
|
+
return <Recuento c={c} ctx={ctx} />;
|
|
425
|
+
case 'capitulo':
|
|
426
|
+
return <Capitulo c={c} ctx={ctx} />;
|
|
427
|
+
case 'vigilancia':
|
|
428
|
+
return <Vigilancia c={c} ctx={ctx} />;
|
|
429
|
+
case 'cintillo':
|
|
430
|
+
return <Cintillo c={c} ctx={ctx} />;
|
|
431
|
+
case 'fecha':
|
|
432
|
+
return <Fecha c={c} ctx={ctx} />;
|
|
433
|
+
case 'calendario':
|
|
434
|
+
return <Calendario c={c} ctx={ctx} />;
|
|
435
|
+
case 'dividida':
|
|
436
|
+
return <Dividida c={c} ctx={ctx} />;
|
|
437
|
+
default:
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Modos de imagen por escena: la imagen (mundo, fondo y capas) cambia a rayos X, plano azul (blueprint) u holograma
|
|
2
|
+
// justo en una palabra, como en los videos que "abren" el cuerpo o una máquina para explicar lo de adentro.
|
|
3
|
+
// Se hace con filtros y capas de mezcla sobre lo ya dibujado: funciona igual en 2D, en 3D (el canvas también se
|
|
4
|
+
// filtra) y con componentes propios, sin dibujar nada dos veces.
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import {AbsoluteFill} from 'remotion';
|
|
7
|
+
import {H, W, clamp, easeIO} from '../estilo';
|
|
8
|
+
import type {Datos} from '../tipos';
|
|
9
|
+
import {azar, cuando, type CtxEscena} from './util';
|
|
10
|
+
|
|
11
|
+
export const MODOS = ['rayosx', 'plano', 'holograma'] as const;
|
|
12
|
+
export type ModoImagen = (typeof MODOS)[number];
|
|
13
|
+
|
|
14
|
+
const ENTRADA = 0.4; // s del barrido de entrada
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Modo activo en el instante `t` de la escena `si` y cuánto lleva (k de 0 a 1 = barrido de entrada).
|
|
18
|
+
* Si la escena anterior terminó en el mismo modo y esta no dice `modoEn`, sigue sin volver a barrer.
|
|
19
|
+
*/
|
|
20
|
+
export function modoDe(d: Datos, si: number, t: number): {modo: ModoImagen; k: number; sale: number} | null {
|
|
21
|
+
const e = d.escenas[si];
|
|
22
|
+
const modo = e?.p.modo as ModoImagen | undefined;
|
|
23
|
+
if (!modo || !MODOS.includes(modo)) return null;
|
|
24
|
+
const ctx: CtxEscena = {t, dur: e.dur, habla: e.habla, palabras: e.palabras ?? [], tema: {} as any, semilla: ''};
|
|
25
|
+
const prev = si > 0 ? d.escenas[si - 1] : null;
|
|
26
|
+
const sigue = prev?.p.modo === modo && prev.p.modoHasta == null && e.p.modoEn == null;
|
|
27
|
+
const t0 = sigue ? -ENTRADA : cuando(e.p.modoEn ?? 0, ctx, 0);
|
|
28
|
+
if (t < t0) return null;
|
|
29
|
+
// `modoHasta`: vuelve a la imagen normal en esa palabra (con el mismo barrido al revés)
|
|
30
|
+
let sale = 0;
|
|
31
|
+
if (e.p.modoHasta != null) {
|
|
32
|
+
const t1 = cuando(e.p.modoHasta, ctx, e.dur);
|
|
33
|
+
if (t >= t1 + ENTRADA) return null;
|
|
34
|
+
sale = clamp((t - t1) / ENTRADA);
|
|
35
|
+
}
|
|
36
|
+
return {modo, k: clamp((t - t0) / ENTRADA), sale};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Filtro CSS de cada modo, con su intensidad `q` (0 = imagen normal, 1 = modo completo). En looks claros (cielo,
|
|
41
|
+
* papel) se invierte la imagen para que el fondo quede oscuro y lo sólido brille; en looks oscuros no hace falta.
|
|
42
|
+
*/
|
|
43
|
+
function filtro(modo: ModoImagen, q: number, invertir: boolean): string {
|
|
44
|
+
if (q <= 0) return 'none';
|
|
45
|
+
const inv = invertir ? q : 0;
|
|
46
|
+
if (modo === 'rayosx') return `grayscale(${q}) invert(${0.92 * inv}) contrast(${1 + (invertir ? 0.45 : 0.6) * q}) brightness(${1 + (invertir ? -0.08 : 0.18) * q})`;
|
|
47
|
+
if (modo === 'plano') return `grayscale(${q}) invert(${inv}) contrast(${1 + 0.9 * q}) brightness(${1 + 0.1 * q})`;
|
|
48
|
+
return `grayscale(${q}) invert(${0.88 * inv}) contrast(${1 + 0.35 * q}) brightness(${1 + 0.1 * q})`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const TINTE: Record<ModoImagen, {color: string; mezcla: React.CSSProperties['mixBlendMode']}> = {
|
|
52
|
+
// lo claro queda cian brillante sobre negro
|
|
53
|
+
rayosx: {color: '#38D4FF', mezcla: 'color'},
|
|
54
|
+
// fondo azul de plano con líneas blancas: el azul entra por "screen" (lo negro se vuelve azul, lo blanco sigue blanco)
|
|
55
|
+
plano: {color: '#1747B8', mezcla: 'screen'},
|
|
56
|
+
// azul translúcido sobre oscuro, como proyectado
|
|
57
|
+
holograma: {color: '#3D8BFF', mezcla: 'color'},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Rejilla fina + gruesa (plano y holograma). */
|
|
61
|
+
const Rejilla: React.FC<{color: string; fuerza: number; paso?: number}> = ({color, fuerza, paso = 60}) => (
|
|
62
|
+
<AbsoluteFill
|
|
63
|
+
style={{
|
|
64
|
+
opacity: fuerza,
|
|
65
|
+
backgroundImage: [
|
|
66
|
+
`linear-gradient(${color} 2px, transparent 2px)`,
|
|
67
|
+
`linear-gradient(90deg, ${color} 2px, transparent 2px)`,
|
|
68
|
+
`linear-gradient(${color} 1px, transparent 1px)`,
|
|
69
|
+
`linear-gradient(90deg, ${color} 1px, transparent 1px)`,
|
|
70
|
+
].join(','),
|
|
71
|
+
backgroundSize: `${paso * 5}px ${paso * 5}px, ${paso * 5}px ${paso * 5}px, ${paso}px ${paso}px, ${paso}px ${paso}px`,
|
|
72
|
+
backgroundPosition: '-1px -1px',
|
|
73
|
+
}}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
/** Envuelve la imagen del video (mundo + escena) y le aplica el modo de la escena actual. */
|
|
78
|
+
export const Modo: React.FC<{d: Datos; si: number; t: number; T: number; claro: boolean; children: React.ReactNode}> = ({d, si, t, T, claro, children}) => {
|
|
79
|
+
const m = modoDe(d, si, t);
|
|
80
|
+
if (!m) return <>{children}</>;
|
|
81
|
+
const invertir = typeof d.escenas[si].p.modoInvertir === 'boolean' ? d.escenas[si].p.modoInvertir : claro;
|
|
82
|
+
const {modo, k, sale} = m;
|
|
83
|
+
const q = easeIO(k) * (1 - easeIO(sale));
|
|
84
|
+
const tinte = TINTE[modo];
|
|
85
|
+
// línea de barrido: baja por la pantalla al entrar y sube al salir; tapa el paso intermedio del filtro
|
|
86
|
+
const barrido = k < 1 ? easeIO(k) : sale > 0 ? 1 - easeIO(sale) : -1;
|
|
87
|
+
const parpadeo = modo === 'holograma' ? 0.94 + 0.06 * azar(`holo-${Math.floor(T * 15)}`) : 1;
|
|
88
|
+
const banda = ((T * 0.35) % 1.3) - 0.15; // banda de escaneo del holograma, cada ~3,7 s
|
|
89
|
+
return (
|
|
90
|
+
<AbsoluteFill style={{isolation: 'isolate'}}>
|
|
91
|
+
<AbsoluteFill style={{filter: filtro(modo, q, invertir)}}>{children}</AbsoluteFill>
|
|
92
|
+
<AbsoluteFill style={{background: tinte.color, mixBlendMode: tinte.mezcla, opacity: (modo === 'plano' ? 1 : 0.9) * q}} />
|
|
93
|
+
{modo === 'plano' ? <Rejilla color="rgba(255,255,255,0.3)" fuerza={q} /> : null}
|
|
94
|
+
{modo === 'holograma' ? (
|
|
95
|
+
<>
|
|
96
|
+
<Rejilla color="rgba(120,200,255,0.30)" fuerza={q * parpadeo} paso={48} />
|
|
97
|
+
<AbsoluteFill style={{opacity: q * 0.18, backgroundImage: 'repeating-linear-gradient(0deg, rgba(160,220,255,0.6) 0 2px, transparent 2px 6px)'}} />
|
|
98
|
+
<div style={{position: 'absolute', left: 0, width: W, top: banda * H, height: 160, opacity: q * 0.35, background: 'linear-gradient(transparent, rgba(140,210,255,0.55), transparent)'}} />
|
|
99
|
+
</>
|
|
100
|
+
) : null}
|
|
101
|
+
{modo === 'rayosx' ? (
|
|
102
|
+
// borde oscuro y grano fino de placa
|
|
103
|
+
<AbsoluteFill style={{opacity: q, background: 'radial-gradient(ellipse 75% 60% at 50% 50%, transparent 55%, rgba(0,8,20,0.75) 100%)'}} />
|
|
104
|
+
) : null}
|
|
105
|
+
{barrido >= 0 ? (
|
|
106
|
+
<div
|
|
107
|
+
style={{
|
|
108
|
+
position: 'absolute',
|
|
109
|
+
left: 0,
|
|
110
|
+
width: W,
|
|
111
|
+
top: barrido * H - 3,
|
|
112
|
+
height: 6,
|
|
113
|
+
background: modo === 'plano' ? '#FFFFFF' : '#9BE8FF',
|
|
114
|
+
boxShadow: `0 0 28px 10px ${modo === 'plano' ? 'rgba(255,255,255,0.6)' : 'rgba(80,210,255,0.7)'}`,
|
|
115
|
+
}}
|
|
116
|
+
/>
|
|
117
|
+
) : null}
|
|
118
|
+
</AbsoluteFill>
|
|
119
|
+
);
|
|
120
|
+
};
|
|
@@ -36,6 +36,20 @@ function frases(palabras: Palabra[], max: number): Palabra[][] {
|
|
|
36
36
|
return out;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Con `coloresClave`, cada palabra clave de la escena toma el siguiente color de la lista (la 1.ª rojo, la 2.ª naranja…).
|
|
41
|
+
* Cuenta las claves dichas antes que `w` en la escena, con la misma regla de `claveDe` palabra por palabra.
|
|
42
|
+
*/
|
|
43
|
+
function colorRotativo(colores: string[] | undefined, palabras: Palabra[], w: Palabra, claves?: string[]): string | undefined {
|
|
44
|
+
if (!colores?.length) return undefined;
|
|
45
|
+
let n = 0;
|
|
46
|
+
for (const p of palabras) {
|
|
47
|
+
if (p === w || p[1] >= w[1]) break;
|
|
48
|
+
if (claveDe([p], claves) === 0) n++;
|
|
49
|
+
}
|
|
50
|
+
return colores[n % colores.length];
|
|
51
|
+
}
|
|
52
|
+
|
|
39
53
|
/** Índice de la palabra clave de la frase (-1 = ninguna). */
|
|
40
54
|
function claveDe(g: Palabra[], claves?: string[]): number {
|
|
41
55
|
if (claves?.length) {
|
|
@@ -80,9 +94,10 @@ export const Subtitulos: React.FC<{tema: Tema; palabras: Palabra[]; t: number; d
|
|
|
80
94
|
const [w] = g[0];
|
|
81
95
|
// con una palabra por frase, la clave se decide sobre toda la escena: las marcadas, las cifras y las largas
|
|
82
96
|
const acento = claveDe([g[0]], claves) === 0;
|
|
97
|
+
const colorClave = acento ? colorRotativo(S.coloresClave, palabras, g[0], claves) ?? tema.colores.acento : S.color;
|
|
83
98
|
return (
|
|
84
99
|
<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:
|
|
100
|
+
<span style={{fontFamily: tema.fuentes.texto, fontWeight: 900, fontSize: S.tam * 1.25, color: colorClave, 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
101
|
{texto(w)}
|
|
87
102
|
</span>
|
|
88
103
|
</div>
|
|
@@ -133,7 +148,7 @@ export const Subtitulos: React.FC<{tema: Tema; palabras: Palabra[]; t: number; d
|
|
|
133
148
|
fontWeight: 900,
|
|
134
149
|
fontSize: tam,
|
|
135
150
|
lineHeight: 1.12,
|
|
136
|
-
color: i === clave ? tema.colores.acento : S.color,
|
|
151
|
+
color: i === clave ? colorRotativo(S.coloresClave, palabras, g[i], claves) ?? tema.colores.acento : S.color,
|
|
137
152
|
WebkitTextStroke: `${S.contorno ?? 10}px #000`,
|
|
138
153
|
paintOrder: 'stroke fill',
|
|
139
154
|
textShadow: '0 6px 0 rgba(0,0,0,0.4)',
|
package/remotion/motor/tema.ts
CHANGED
|
@@ -49,6 +49,8 @@ export type Tema = {
|
|
|
49
49
|
pendiente: string; // palabras por decir
|
|
50
50
|
resalte: string; // caja de la palabra actual ('' = sin caja)
|
|
51
51
|
maxPalabras: number;
|
|
52
|
+
/** Colores que rotan entre las palabras clave (`palabra` y `pop`): ['#FF3B30', '#FF9F0A', '#FFD60A', '#34C759']. */
|
|
53
|
+
coloresClave?: string[];
|
|
52
54
|
};
|
|
53
55
|
transicion: 'corte' | 'fundido' | 'deslizar' | 'zoom' | 'barrido' | 'empuje' | 'mascara' | 'destello';
|
|
54
56
|
/** Ritmo de las animaciones: duraciones (s) y curva de las entradas. Sin esto: 0.5 / 0.35 / 0.35 y 'suave'. */
|
|
@@ -60,13 +62,19 @@ export type Tema = {
|
|
|
60
62
|
desenfoqueBordes?: number; // px de blur arriba y abajo (efecto maqueta / profundidad)
|
|
61
63
|
vinetaColor?: string;
|
|
62
64
|
};
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Rótulo grande de cada escena: 'normal' (color del texto), 'contorno' (blanco con contorno oscuro, clave en acento),
|
|
67
|
+
* 'pastilla' (caja oscura inclinada), 'sello' (sello de goma con borde que cae de golpe), 'paso' (titular arriba a la
|
|
68
|
+
* izquierda con insignia numerada) o 'pixel' (letra pixel en una caja de color, arriba).
|
|
69
|
+
*/
|
|
70
|
+
rotulo?: {estilo?: EstiloRotulo; inclinar?: number; y?: number; tam?: number; color?: string};
|
|
65
71
|
marca: 'esquina' | 'arriba' | 'ninguna';
|
|
66
72
|
/** Movimiento suave de cámara por defecto en cada escena (zoom de 1 a este valor). */
|
|
67
73
|
zoomLento: number;
|
|
68
74
|
};
|
|
69
75
|
|
|
76
|
+
export type EstiloRotulo = 'normal' | 'contorno' | 'pastilla' | 'sello' | 'paso' | 'pixel';
|
|
77
|
+
|
|
70
78
|
const SUB_BASE = {tipo: 'karaoke' as const, y: 330, tam: 54, maxPalabras: 7};
|
|
71
79
|
|
|
72
80
|
export const ESTILOS: Record<string, Tema> = {
|