morfeo 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/morfeo.mjs +35 -1
- package/index.d.ts +57 -5
- package/lib/config.mjs +11 -8
- package/lib/estructura.mjs +104 -0
- package/lib/guion.mjs +25 -8
- package/lib/hoja.mjs +98 -0
- package/lib/imagenes.mjs +2 -1
- package/lib/personajes.mjs +1 -1
- package/lib/recursos.mjs +2 -1
- package/lib/render.mjs +9 -2
- package/package.json +2 -1
- package/python/preparar.py +1 -1
- package/remotion/estilo.ts +13 -0
- package/remotion/kit/ilustrado.tsx +220 -0
- package/remotion/kit/index.ts +6 -0
- package/remotion/kit/tres.tsx +328 -0
- package/remotion/motor/Video.tsx +171 -20
- package/remotion/motor/anotaciones.tsx +296 -0
- package/remotion/motor/capas.tsx +50 -27
- package/remotion/motor/fondo.tsx +56 -2
- package/remotion/motor/plantillas.tsx +26 -1
- package/remotion/motor/subtitulos.tsx +159 -36
- package/remotion/motor/tema.ts +46 -17
- package/remotion/motor/util.ts +3 -0
- package/remotion/tipos.ts +2 -0
package/remotion/motor/Video.tsx
CHANGED
|
@@ -1,44 +1,107 @@
|
|
|
1
|
-
// Composición del motor de capas: fondo + cámara + capas por escena, transiciones, subtítulos y marca.
|
|
1
|
+
// Composición del motor de capas: mundo o fondo + cámara + capas por escena, transiciones, acabado, subtítulos y marca.
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import {AbsoluteFill, Audio, staticFile} from 'remotion';
|
|
4
4
|
import {Avatar, MarcaContexto, useMarca} from '../piezas';
|
|
5
|
-
import {FPS, W, clamp, easeIO, easeOut, lerp} from '../estilo';
|
|
5
|
+
import {FPS, H, W, ancho, clamp, easeIO, easeOut, lerp, resorte} from '../estilo';
|
|
6
6
|
import type {Datos, Escena} from '../tipos';
|
|
7
7
|
import {Capas} from './capas';
|
|
8
|
-
import {FondoTema, Textura, Vineta} from './fondo';
|
|
8
|
+
import {FondoTema, Post, Textura, Vineta} from './fondo';
|
|
9
9
|
import {capasDe} from './plantillas';
|
|
10
10
|
import {Subtitulos} from './subtitulos';
|
|
11
11
|
import {mezclar, temaDe, type Tema} from './tema';
|
|
12
|
-
import type
|
|
12
|
+
import {azar, cuando, partesResaltadas, type CtxEscena} from './util';
|
|
13
13
|
|
|
14
14
|
const TRANSICION = 0.35;
|
|
15
15
|
|
|
16
16
|
/** Valor animado de cámara: número fijo o [inicio, fin] a lo largo de la escena. */
|
|
17
17
|
const rango = (v: unknown, k: number, porDefecto: number) => (Array.isArray(v) ? lerp(v[0], v[1], k) : typeof v === 'number' ? v : porDefecto);
|
|
18
18
|
|
|
19
|
-
const EscenaVista: React.FC<{e: Escena; t: number; tema: Tema; semilla: string}> = ({e, t, tema, semilla}) => {
|
|
19
|
+
const EscenaVista: React.FC<{e: Escena; t: number; tema: Tema; semilla: string; sinFondo?: boolean}> = ({e, t, tema, semilla, sinFondo}) => {
|
|
20
20
|
const marca = useMarca();
|
|
21
21
|
const p = e.p;
|
|
22
22
|
const temaEsc = p.tema ? mezclar(tema, p.tema) : tema;
|
|
23
23
|
const ctx: CtxEscena = {t, dur: e.dur, habla: e.habla, palabras: e.palabras ?? [], tema: temaEsc, semilla: `${semilla}-${e.t0}`};
|
|
24
24
|
const cam = p.camara ?? {};
|
|
25
25
|
const k = easeIO(clamp((t - (cam.en ?? 0)) / (cam.duracion ?? e.dur)));
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
26
|
+
// sin `camara`, cada escena tiene una deriva lenta propia: acerca y se desplaza un poco hacia un lado distinto
|
|
27
|
+
const fase = azar(`${semilla}-${e.t0}-deriva`) * 6.28;
|
|
28
|
+
const avance = clamp(t / e.dur);
|
|
29
|
+
const deriva = sinFondo ? 0 : (temaEsc.zoomLento - 1) * 420;
|
|
30
|
+
const zoom = rango(cam.zoom, k, sinFondo ? 1 : lerp(1, temaEsc.zoomLento, easeOut(avance) * 0.55 + avance * 0.45));
|
|
31
|
+
const cx = rango(cam.x, k, Math.cos(fase) * deriva * avance);
|
|
32
|
+
const cy = rango(cam.y, k, Math.sin(fase) * deriva * 0.6 * avance);
|
|
33
|
+
// temblor: varias frecuencias y en los dos ejes, para que no se sienta como un péndulo
|
|
34
|
+
const td = cam.temblor?.duracion ?? 0.4;
|
|
35
|
+
const tf = cam.temblor && t < td ? (cam.temblor.fuerza ?? 12) * (1 - t / td) ** 2 : 0;
|
|
36
|
+
const tx = tf ? (Math.sin(t * 90) + Math.sin(t * 57 + 1) * 0.6) * tf * 0.7 : 0;
|
|
37
|
+
const ty = tf ? (Math.cos(t * 73 + 2) + Math.sin(t * 41) * 0.5) * tf * 0.5 : 0;
|
|
38
|
+
const capas = capasDe(e.tipo, p, temaEsc, marca).filter((c: any) => !(sinFondo && c.enMundo));
|
|
30
39
|
return (
|
|
31
40
|
<AbsoluteFill>
|
|
32
|
-
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
{sinFondo ? null : (
|
|
42
|
+
<AbsoluteFill style={{transform: `scale(${1 + (zoom - 1) * 0.5}) translate(${-cx * 0.3}px, ${-cy * 0.3}px)`}}>
|
|
43
|
+
<FondoTema tema={temaEsc} fondo={p.fondo} t={t + e.t0} semilla={semilla} ctx={ctx} />
|
|
44
|
+
</AbsoluteFill>
|
|
45
|
+
)}
|
|
46
|
+
<AbsoluteFill style={{transform: `scale(${zoom}) translate(${-cx + tx}px, ${-cy + ty}px)`, transformOrigin: `${540 + (cam.origen?.[0] ?? 0)}px ${960 + (cam.origen?.[1] ?? 0)}px`}}>
|
|
47
|
+
<Capas capas={capas} ctx={ctx} />
|
|
37
48
|
</AbsoluteFill>
|
|
38
49
|
</AbsoluteFill>
|
|
39
50
|
);
|
|
40
51
|
};
|
|
41
52
|
|
|
53
|
+
type Toma = {x: number; y: number; zoom: number; giro: number};
|
|
54
|
+
const TOMA0: Toma = {x: 540, y: 960, zoom: 1, giro: 0};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Cámara del mundo. Cada escena da su `toma: {x, y, zoom, giro}` (el punto del mundo que queda al centro);
|
|
58
|
+
* la cámara viaja desde donde terminó la escena anterior y luego sigue derivando despacio (`deriva`, o hacia `a`).
|
|
59
|
+
* Sin `toma`, la escena conserva la anterior. `corte: true` salta sin viaje.
|
|
60
|
+
*/
|
|
61
|
+
export function camaraMundo(d: Datos, si: number, t: number): Toma {
|
|
62
|
+
const destino = (i: number): Toma => {
|
|
63
|
+
for (let j = i; j >= 0; j--) if (d.escenas[j].p.toma) return {...TOMA0, ...d.escenas[j].p.toma};
|
|
64
|
+
return TOMA0;
|
|
65
|
+
};
|
|
66
|
+
const final = (i: number): Toma => {
|
|
67
|
+
const tm = d.escenas[i].p.toma ?? {};
|
|
68
|
+
const base = destino(i);
|
|
69
|
+
if (tm.a) return {...base, ...tm.a};
|
|
70
|
+
return {...base, zoom: base.zoom * (1 + (tm.deriva ?? 0.05))};
|
|
71
|
+
};
|
|
72
|
+
const e = d.escenas[si];
|
|
73
|
+
const tm = e.p.toma ?? {};
|
|
74
|
+
const a = destino(si);
|
|
75
|
+
const b = final(si);
|
|
76
|
+
const viaje = tm.corte || si === 0 ? 0 : tm.duracion ?? Math.min(1.1, e.dur * 0.55);
|
|
77
|
+
const kd = clamp((t - viaje * 0.6) / Math.max(0.01, e.dur - viaje * 0.6));
|
|
78
|
+
const kq = tm.a ? easeIO(kd) : kd;
|
|
79
|
+
const actual: Toma = {x: lerp(a.x, b.x, kq), y: lerp(a.y, b.y, kq), zoom: lerp(a.zoom, b.zoom, kq), giro: lerp(a.giro, b.giro, kq)};
|
|
80
|
+
if (viaje <= 0 || t >= viaje) return actual;
|
|
81
|
+
const o = final(si - 1);
|
|
82
|
+
const kv = easeIO(t / viaje);
|
|
83
|
+
// el zoom se interpola en escala logarítmica: así acercar y alejar se sienten igual de rápidos
|
|
84
|
+
return {x: lerp(o.x, actual.x, kv), y: lerp(o.y, actual.y, kv), zoom: Math.exp(lerp(Math.log(o.zoom), Math.log(actual.zoom), kv)), giro: lerp(o.giro, actual.giro, kv)};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** El mundo del video: capas que persisten de escena en escena, vistas por la cámara. Las capas `enMundo` de la escena actual viven aquí. */
|
|
88
|
+
const Mundo: React.FC<{d: Datos; si: number; t: number; T: number; tema: Tema}> = ({d, si, t, T, tema}) => {
|
|
89
|
+
const marca = useMarca();
|
|
90
|
+
const e = d.escenas[si];
|
|
91
|
+
const cam = camaraMundo(d, si, t);
|
|
92
|
+
const palabras = d.escenas.flatMap((x) => (x.palabras ?? []).map(([w, a, b]) => [w, a + x.t0, b + x.t0] as [string, number, number]));
|
|
93
|
+
const ctxMundo: CtxEscena = {t: T, dur: d.duracion, habla: [0, d.duracion], palabras, tema, semilla: `${d.id}-mundo`, inicios: d.escenas.map((x) => x.t0), escena: si + 1, tEscena: t, camara: cam};
|
|
94
|
+
const temaEsc = e.p.tema ? mezclar(tema, e.p.tema) : tema;
|
|
95
|
+
const ctxEsc: CtxEscena = {t, dur: e.dur, habla: e.habla, palabras: e.palabras ?? [], tema: temaEsc, semilla: `${d.id}-${e.t0}`};
|
|
96
|
+
const propias = capasDe(e.tipo, e.p, temaEsc, marca).filter((c: any) => c.enMundo);
|
|
97
|
+
return (
|
|
98
|
+
<AbsoluteFill style={{transform: `translate(540px, 960px) scale(${cam.zoom}) rotate(${cam.giro}deg) translate(${-cam.x}px, ${-cam.y}px)`, transformOrigin: '0 0'}}>
|
|
99
|
+
<Capas capas={d.mundo as any} ctx={ctxMundo} />
|
|
100
|
+
{propias.length ? <Capas capas={propias} ctx={ctxEsc} /> : null}
|
|
101
|
+
</AbsoluteFill>
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
42
105
|
/** Capas fijas del video (HUD): usan el tiempo de todo el video; `cambios` las actualiza por escena o palabra. */
|
|
43
106
|
const Hud: React.FC<{d: Datos; T: number; tema: Tema}> = ({d, T, tema}) => {
|
|
44
107
|
const palabras = d.escenas.flatMap((e) => (e.palabras ?? []).map(([w, a, b]) => [w, a + e.t0, b + e.t0] as [string, number, number]));
|
|
@@ -49,6 +112,68 @@ const Hud: React.FC<{d: Datos; T: number; tema: Tema}> = ({d, T, tema}) => {
|
|
|
49
112
|
return <Capas capas={d.hud as any} ctx={ctx} />;
|
|
50
113
|
};
|
|
51
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Rótulo de la escena: el segundo nivel de texto (grande y corto) que resume el momento: "¿CUÁNTO SUBIÓ?", "DÍA 21".
|
|
117
|
+
* Va abajo si los subtítulos están arriba, y arriba si están abajo. `*palabra*` va en color de acento.
|
|
118
|
+
*/
|
|
119
|
+
const Rotulo: React.FC<{e: Escena; t: number; tema: Tema}> = ({e, t, tema}) => {
|
|
120
|
+
const texto = String(e.p.rotulo ?? '');
|
|
121
|
+
if (!texto) return null;
|
|
122
|
+
const ctx: CtxEscena = {t, dur: e.dur, habla: e.habla, palabras: e.palabras ?? [], tema, semilla: ''};
|
|
123
|
+
const t0 = cuando(e.p.rotuloEn, ctx, 0.12);
|
|
124
|
+
if (t < t0) return null;
|
|
125
|
+
const k = clamp((t - t0) / 0.45);
|
|
126
|
+
const S = tema.subtitulos;
|
|
127
|
+
const abajo = S.tipo === 'ninguno' || S.y < 900;
|
|
128
|
+
const may = tema.tituloMayusculas !== false;
|
|
129
|
+
const limpio = texto.replace(/\*/g, '');
|
|
130
|
+
const fuente = tema.fuentes.titulo;
|
|
131
|
+
// cabe en 940 px: se achica si hace falta (el ancho se mide con la fuente ya cargada)
|
|
132
|
+
let tam = e.p.rotuloTam ?? 104;
|
|
133
|
+
try {
|
|
134
|
+
const w = ancho(may ? limpio.toUpperCase() : limpio, fuente, tam, 900);
|
|
135
|
+
if (w > 940) tam = Math.max(56, Math.floor((tam * 940) / w));
|
|
136
|
+
} catch {}
|
|
137
|
+
const claro = tema.colores.texto !== '#FFFFFF';
|
|
138
|
+
const contorno = !claro && (S.contorno ?? 0) > 0;
|
|
139
|
+
return (
|
|
140
|
+
<div style={{position: 'absolute', left: 50, width: W - 100, top: abajo ? 1470 : 330, transform: `translate(0, -50%) scale(${0.86 + 0.14 * resorte(k, 0.3)})`, opacity: clamp(k * 3), textAlign: 'center', pointerEvents: 'none'}}>
|
|
141
|
+
<span
|
|
142
|
+
style={{
|
|
143
|
+
fontFamily: fuente,
|
|
144
|
+
fontWeight: 900,
|
|
145
|
+
fontSize: tam,
|
|
146
|
+
lineHeight: 1.05,
|
|
147
|
+
color: tema.colores.texto,
|
|
148
|
+
textTransform: may ? 'uppercase' : undefined,
|
|
149
|
+
WebkitTextStroke: contorno ? `${S.contorno}px #000` : undefined,
|
|
150
|
+
paintOrder: 'stroke fill',
|
|
151
|
+
textShadow: claro ? undefined : '0 6px 22px rgba(0,0,0,0.55)',
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
{partesResaltadas(texto).map(([p, r], i) => (
|
|
155
|
+
<span key={i} style={r ? {color: tema.colores.acento} : undefined}>
|
|
156
|
+
{p}
|
|
157
|
+
</span>
|
|
158
|
+
))}
|
|
159
|
+
</span>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Pastilla fija de la serie arriba al centro: "GYM CON CIENCIA · PARTE 5". */
|
|
165
|
+
const Serie: React.FC<{texto: string; tema: Tema}> = ({texto, tema}) => {
|
|
166
|
+
const claro = tema.colores.texto !== '#FFFFFF';
|
|
167
|
+
return (
|
|
168
|
+
<div style={{position: 'absolute', left: 0, width: W, top: 132, display: 'flex', justifyContent: 'center', pointerEvents: 'none'}}>
|
|
169
|
+
<div style={{display: 'flex', alignItems: 'center', gap: 12, background: claro ? 'rgba(255,255,255,0.88)' : 'rgba(10,10,14,0.72)', color: claro ? '#141414' : '#FFFFFF', border: `2px solid ${claro ? 'rgba(0,0,0,0.12)' : 'rgba(255,255,255,0.14)'}`, borderRadius: 999, padding: '8px 26px', fontFamily: tema.fuentes.mono, fontWeight: 800, fontSize: 26, letterSpacing: 2, textTransform: 'uppercase', whiteSpace: 'nowrap'}}>
|
|
170
|
+
<span style={{width: 10, height: 10, borderRadius: '50%', background: tema.colores.acento}} />
|
|
171
|
+
{texto}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
};
|
|
176
|
+
|
|
52
177
|
/** Etiqueta de la marca en la esquina (avatar + nombre). */
|
|
53
178
|
const MarcaEsquina: React.FC<{tema: Tema}> = ({tema}) => {
|
|
54
179
|
const marca = useMarca();
|
|
@@ -74,12 +199,17 @@ export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string,
|
|
|
74
199
|
const e = d.escenas[si];
|
|
75
200
|
const t = T - e.t0;
|
|
76
201
|
const semilla = d.id;
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
202
|
+
const conMundo = !!d.mundo?.length;
|
|
203
|
+
const DUR = tema.movimiento?.transicion ?? TRANSICION;
|
|
204
|
+
// con mundo, la transición es el viaje de la cámara: las capas de pantalla solo entran con su propia animación
|
|
205
|
+
const trans = conMundo ? 'corte' : ((e.p.transicion ?? tema.transicion) as string);
|
|
206
|
+
const enTransicion = si > 0 && t < DUR && trans !== 'corte';
|
|
207
|
+
const lin = clamp(t / DUR);
|
|
208
|
+
const k = easeOut(lin);
|
|
80
209
|
const prev = si > 0 ? d.escenas[si - 1] : null;
|
|
81
210
|
let estiloActual: React.CSSProperties = {};
|
|
82
211
|
let estiloPrevio: React.CSSProperties = {};
|
|
212
|
+
let destello = 0;
|
|
83
213
|
if (enTransicion) {
|
|
84
214
|
if (trans === 'fundido') estiloActual = {opacity: k};
|
|
85
215
|
else if (trans === 'deslizar') {
|
|
@@ -88,23 +218,44 @@ export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string,
|
|
|
88
218
|
} else if (trans === 'zoom') {
|
|
89
219
|
estiloActual = {transform: `scale(${lerp(1.15, 1, k)})`, opacity: k};
|
|
90
220
|
estiloPrevio = {transform: `scale(${lerp(1, 0.9, k)})`};
|
|
221
|
+
} else if (trans === 'barrido') {
|
|
222
|
+
// latigazo: las dos escenas pasan de lado con desenfoque de movimiento, más fuerte a la mitad
|
|
223
|
+
const q = easeIO(lin);
|
|
224
|
+
const blur = Math.sin(lin * Math.PI) * 26;
|
|
225
|
+
const filter = blur > 0.5 ? `blur(${blur}px)` : undefined;
|
|
226
|
+
estiloActual = {transform: `translateX(${(1 - q) * W}px)`, filter};
|
|
227
|
+
estiloPrevio = {transform: `translateX(${-q * W}px)`, filter};
|
|
228
|
+
} else if (trans === 'empuje') {
|
|
229
|
+
const q = easeIO(lin);
|
|
230
|
+
estiloActual = {transform: `translateY(${(1 - q) * H}px)`};
|
|
231
|
+
estiloPrevio = {transform: `translateY(${-q * H}px)`};
|
|
232
|
+
} else if (trans === 'mascara') {
|
|
233
|
+
estiloActual = {clipPath: `circle(${easeIO(lin) * 1250}px at 540px 960px)`};
|
|
234
|
+
} else if (trans === 'destello') {
|
|
235
|
+
estiloActual = {opacity: lin < 0.4 ? 0 : 1};
|
|
236
|
+
destello = Math.sin(lin * Math.PI) ** 1.5;
|
|
91
237
|
}
|
|
92
238
|
}
|
|
93
239
|
return (
|
|
94
240
|
<AbsoluteFill style={{background: '#000', overflow: 'hidden'}}>
|
|
241
|
+
{conMundo ? <Mundo d={d} si={si} t={t} T={T} tema={tema} /> : null}
|
|
95
242
|
{enTransicion && prev ? (
|
|
96
243
|
<AbsoluteFill style={estiloPrevio}>
|
|
97
244
|
<EscenaVista e={prev} t={prev.dur + t} tema={tema} semilla={semilla} />
|
|
98
245
|
</AbsoluteFill>
|
|
99
246
|
) : null}
|
|
100
247
|
<AbsoluteFill style={estiloActual}>
|
|
101
|
-
<EscenaVista e={e} t={t} tema={tema} semilla={semilla} />
|
|
248
|
+
<EscenaVista e={e} t={t} tema={tema} semilla={semilla} sinFondo={conMundo} />
|
|
102
249
|
</AbsoluteFill>
|
|
250
|
+
{destello > 0 ? <AbsoluteFill style={{background: '#fff', opacity: destello}} /> : null}
|
|
251
|
+
<Post tema={tema} />
|
|
103
252
|
{/* la textura va quieta, fuera de la cámara: si se moviera con el zoom, cada cuadro sería distinto y el video pesaría 10 veces más */}
|
|
104
253
|
{tema.textura && tema.textura !== 'ninguna' ? <Textura tipo={tema.textura} /> : null}
|
|
105
|
-
<Vineta fuerza={tema.vineta ?? 0} />
|
|
254
|
+
<Vineta fuerza={tema.vineta ?? 0} color={tema.post?.vinetaColor} />
|
|
106
255
|
{d.hud?.length ? <Hud d={d} T={T} tema={tema} /> : null}
|
|
107
|
-
{e.tipo !== 'cta'
|
|
256
|
+
{d.serie && e.tipo !== 'cta' ? <Serie texto={d.serie} tema={tema} /> : null}
|
|
257
|
+
{e.tipo !== 'cta' ? <Rotulo e={e} t={t} tema={e.p.tema ? mezclar(tema, e.p.tema) : tema} /> : null}
|
|
258
|
+
{e.tipo !== 'cta' && !miniatura ? <Subtitulos tema={e.p.tema ? mezclar(tema, e.p.tema) : tema} palabras={e.palabras ?? []} t={t} dur={e.dur} claves={e.p.claves} /> : null}
|
|
108
259
|
{e.tipo !== 'cta' ? <MarcaEsquina tema={tema} /> : null}
|
|
109
260
|
</AbsoluteFill>
|
|
110
261
|
);
|
|
@@ -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
|
+
};
|