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