morfeo 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/morfeo.mjs +35 -1
- package/index.d.ts +59 -7
- package/lib/config.mjs +11 -8
- package/lib/diccionario.mjs +5 -2
- 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 +24 -12
- 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/modelo3d.tsx +47 -5
- 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
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// Kit 3D: piezas de Three.js para armar el mundo de un video (maqueta en corte, mesa con foco de luz) en un componente propio.
|
|
2
|
+
//
|
|
3
|
+
// import {Lienzo3D, Terreno, Tubo, Arbol, Casa, Mesa, Pila} from 'morfeo/kit';
|
|
4
|
+
//
|
|
5
|
+
// Todo el mundo va en UN solo <Lienzo3D> (Chrome aguanta ~16 contextos WebGL: no pongas un lienzo por objeto).
|
|
6
|
+
// La cámara se mueve por `tomas`: una por escena; viaja desde donde terminó la anterior y luego deriva despacio.
|
|
7
|
+
// Nada usa useFrame: todo depende de las props (`t`, `tEscena`), como pide Remotion.
|
|
8
|
+
import {useThree} from '@react-three/fiber';
|
|
9
|
+
import {ThreeCanvas} from '@remotion/three';
|
|
10
|
+
import React, {useEffect, useLayoutEffect, useMemo, useRef} from 'react';
|
|
11
|
+
import * as THREE from 'three';
|
|
12
|
+
import {RoomEnvironment} from 'three/examples/jsm/environments/RoomEnvironment.js';
|
|
13
|
+
import {azarN} from './ilustrado';
|
|
14
|
+
|
|
15
|
+
export {Objeto3D} from '../motor/objetos3d';
|
|
16
|
+
|
|
17
|
+
type V3 = [number, number, number];
|
|
18
|
+
const suave = (x: number) => {
|
|
19
|
+
const c = Math.max(0, Math.min(1, x));
|
|
20
|
+
return c * c * (3 - 2 * c);
|
|
21
|
+
};
|
|
22
|
+
const mezcla = (a: V3, b: V3, k: number): V3 => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Toma de cámara de una escena: dónde está (`pos`) y a dónde mira (`mirar`). Con `a` se mueve hasta ahí durante la
|
|
26
|
+
* escena; sin `a` se acerca un poco sola (`deriva`, 0.06 = 6 % del camino hacia lo que mira). `corte` salta sin viaje.
|
|
27
|
+
*/
|
|
28
|
+
export type Toma3D = {pos: V3; mirar?: V3; fov?: number; a?: {pos?: V3; mirar?: V3; fov?: number}; viaje?: number; corte?: boolean; deriva?: number};
|
|
29
|
+
type Estado = {pos: V3; mirar: V3; fov: number};
|
|
30
|
+
|
|
31
|
+
/** Cámara del cuadro actual. `tomas[i]` es la de la escena i+1; si falta, se conserva la anterior. */
|
|
32
|
+
export function camara3D(tomas: (Toma3D | undefined | null)[], escena: number, tEscena: number, inicios?: number[]): Estado {
|
|
33
|
+
const de = (i: number): Toma3D => {
|
|
34
|
+
for (let j = Math.min(i, tomas.length - 1); j >= 0; j--) if (tomas[j]) return tomas[j]!;
|
|
35
|
+
return {pos: [6, 4, 8]};
|
|
36
|
+
};
|
|
37
|
+
const inicio = (i: number): Estado => ({pos: de(i).pos, mirar: de(i).mirar ?? [0, 0, 0], fov: de(i).fov ?? 38});
|
|
38
|
+
const final = (i: number): Estado => {
|
|
39
|
+
const tm = tomas[i] ? de(i) : ({...de(i), a: undefined} as Toma3D);
|
|
40
|
+
const a = inicio(i);
|
|
41
|
+
if (tm.a) return {pos: tm.a.pos ?? a.pos, mirar: tm.a.mirar ?? a.mirar, fov: tm.a.fov ?? a.fov};
|
|
42
|
+
return {...a, pos: mezcla(a.pos, a.mirar, tm.deriva ?? 0.06)};
|
|
43
|
+
};
|
|
44
|
+
const i = Math.max(0, escena - 1);
|
|
45
|
+
const tm = de(i);
|
|
46
|
+
const dur = inicios && inicios[i + 1] != null ? inicios[i + 1] - inicios[i] : 5;
|
|
47
|
+
const viaje = tm.corte || i === 0 || !tomas[i] ? 0 : tm.viaje ?? Math.min(1.2, dur * 0.5);
|
|
48
|
+
const a = inicio(i);
|
|
49
|
+
const b = final(i);
|
|
50
|
+
const kd = Math.max(0, Math.min(1, (tEscena - viaje * 0.6) / Math.max(0.01, dur - viaje * 0.6)));
|
|
51
|
+
const kq = tm.a ? suave(kd) : kd;
|
|
52
|
+
const actual: Estado = {pos: mezcla(a.pos, b.pos, kq), mirar: mezcla(a.mirar, b.mirar, kq), fov: a.fov + (b.fov - a.fov) * kq};
|
|
53
|
+
if (viaje <= 0 || tEscena >= viaje) return actual;
|
|
54
|
+
const o = final(i - 1);
|
|
55
|
+
const kv = suave(tEscena / viaje);
|
|
56
|
+
return {pos: mezcla(o.pos, actual.pos, kv), mirar: mezcla(o.mirar, actual.mirar, kv), fov: o.fov + (actual.fov - o.fov) * kv};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const Camara: React.FC<{estado: Estado}> = ({estado}) => {
|
|
60
|
+
const {camera} = useThree();
|
|
61
|
+
useLayoutEffect(() => {
|
|
62
|
+
camera.position.set(...estado.pos);
|
|
63
|
+
camera.lookAt(...estado.mirar);
|
|
64
|
+
const p = camera as THREE.PerspectiveCamera;
|
|
65
|
+
if (p.isPerspectiveCamera) {
|
|
66
|
+
p.fov = estado.fov;
|
|
67
|
+
p.updateProjectionMatrix();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
return null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const Entorno: React.FC<{fuerza: number}> = ({fuerza}) => {
|
|
74
|
+
const {gl, scene} = useThree();
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
const pmrem = new THREE.PMREMGenerator(gl);
|
|
77
|
+
const tex = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
|
78
|
+
scene.environment = tex;
|
|
79
|
+
scene.environmentIntensity = fuerza;
|
|
80
|
+
return () => {
|
|
81
|
+
tex.dispose();
|
|
82
|
+
pmrem.dispose();
|
|
83
|
+
};
|
|
84
|
+
}, [gl, scene, fuerza]);
|
|
85
|
+
return null;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* El lienzo del mundo 3D (1080×1920). `luz`:
|
|
90
|
+
* 'dia' cielo claro, sol con sombras suaves (maquetas, cortes de terreno, low-poly)
|
|
91
|
+
* 'calida' cuarto oscuro con un foco cálido desde arriba y su cono visible (mesa, dinero, objetos)
|
|
92
|
+
*/
|
|
93
|
+
export const Lienzo3D: React.FC<{
|
|
94
|
+
tomas: (Toma3D | undefined | null)[];
|
|
95
|
+
escena?: number;
|
|
96
|
+
tEscena?: number;
|
|
97
|
+
inicios?: number[];
|
|
98
|
+
luz?: 'dia' | 'calida';
|
|
99
|
+
fondo?: string;
|
|
100
|
+
niebla?: [number, number];
|
|
101
|
+
sol?: V3;
|
|
102
|
+
foco?: V3;
|
|
103
|
+
children: React.ReactNode;
|
|
104
|
+
}> = ({tomas, escena = 1, tEscena = 0, inicios, luz = 'dia', fondo, niebla, sol = [9, 15, 8], foco = [0, 10, 1.5], children}) => {
|
|
105
|
+
const estado = camara3D(tomas, escena, tEscena, inicios);
|
|
106
|
+
const color = fondo ?? (luz === 'dia' ? '#BFE3FF' : '#120A06');
|
|
107
|
+
return (
|
|
108
|
+
<div style={{width: 1080, height: 1920}}>
|
|
109
|
+
<ThreeCanvas width={1080} height={1920} shadows gl={{antialias: true}} camera={{fov: estado.fov, position: estado.pos, near: 0.1, far: 400}}>
|
|
110
|
+
<color attach="background" args={[color]} />
|
|
111
|
+
{niebla ? <fog attach="fog" args={[color, niebla[0], niebla[1]]} /> : null}
|
|
112
|
+
<Camara estado={estado} />
|
|
113
|
+
{luz === 'dia' ? (
|
|
114
|
+
<>
|
|
115
|
+
<Entorno fuerza={0.55} />
|
|
116
|
+
<hemisphereLight args={['#ffffff', '#b9a98a', 1.05]} />
|
|
117
|
+
<directionalLight position={sol} intensity={2.3} castShadow shadow-mapSize={[2048, 2048]} shadow-camera-left={-26} shadow-camera-right={26} shadow-camera-top={26} shadow-camera-bottom={-26} shadow-camera-far={90} shadow-bias={-0.0006} />
|
|
118
|
+
</>
|
|
119
|
+
) : (
|
|
120
|
+
<>
|
|
121
|
+
<Entorno fuerza={0.22} />
|
|
122
|
+
<ambientLight color="#6A4630" intensity={0.55} />
|
|
123
|
+
<spotLight position={foco} angle={0.52} penumbra={0.85} decay={0} intensity={3.4} color="#FFDDAA" castShadow shadow-mapSize={[2048, 2048]} shadow-bias={-0.0004} />
|
|
124
|
+
{/* cono de luz visible */}
|
|
125
|
+
<mesh position={[foco[0], foco[1] / 2, foco[2]]}>
|
|
126
|
+
<coneGeometry args={[foco[1] * 0.56, foco[1], 48, 1, true]} />
|
|
127
|
+
<meshBasicMaterial color="#FFD9A0" transparent opacity={0.055} blending={THREE.AdditiveBlending} depthWrite={false} side={THREE.DoubleSide} />
|
|
128
|
+
</mesh>
|
|
129
|
+
</>
|
|
130
|
+
)}
|
|
131
|
+
{children}
|
|
132
|
+
</ThreeCanvas>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
type Pieza = {pos?: V3; rot?: V3; escala?: number};
|
|
138
|
+
const RAD = Math.PI / 180;
|
|
139
|
+
const grupo = (p: Pieza) => ({position: p.pos ?? ([0, 0, 0] as V3), rotation: (p.rot ?? [0, 0, 0]).map((g) => g * RAD) as V3, scale: p.escala ?? 1});
|
|
140
|
+
|
|
141
|
+
/** Bloque de color mate: la pieza base para cajas, muros, banquetas, cualquier cosa. */
|
|
142
|
+
export const Bloque: React.FC<Pieza & {tam: V3; color: string; rugosidad?: number; metal?: number; opacidad?: number; radio?: number}> = ({tam, color, rugosidad = 0.8, metal = 0, opacidad, ...p}) => (
|
|
143
|
+
<mesh {...grupo(p)} castShadow receiveShadow>
|
|
144
|
+
<boxGeometry args={tam} />
|
|
145
|
+
<meshStandardMaterial color={color} roughness={rugosidad} metalness={metal} transparent={opacidad != null} opacity={opacidad ?? 1} />
|
|
146
|
+
</mesh>
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Terreno en corte: capas apiladas hacia abajo desde y = 0 (pasto, tierra, roca...). La cara de enfrente (z = fondo / 2)
|
|
151
|
+
* queda a la vista para mostrar lo que hay bajo tierra. capas: [[color, grosor], ...]
|
|
152
|
+
*/
|
|
153
|
+
export const Terreno: React.FC<Pieza & {ancho?: number; fondo?: number; capas?: [string, number][]}> = ({ancho = 22, fondo = 9, capas = [['#6CC04A', 0.35], ['#8A5A3B', 3.2], ['#77726E', 3]], ...p}) => {
|
|
154
|
+
let y = 0;
|
|
155
|
+
return (
|
|
156
|
+
<group {...grupo(p)}>
|
|
157
|
+
{capas.map(([color, grosor], i) => {
|
|
158
|
+
const centro = y - grosor / 2;
|
|
159
|
+
y -= grosor;
|
|
160
|
+
return (
|
|
161
|
+
<mesh key={i} position={[0, centro, 0]} receiveShadow castShadow={i === 0}>
|
|
162
|
+
<boxGeometry args={[ancho, grosor, fondo]} />
|
|
163
|
+
<meshStandardMaterial color={color} roughness={0.95} />
|
|
164
|
+
</mesh>
|
|
165
|
+
);
|
|
166
|
+
})}
|
|
167
|
+
</group>
|
|
168
|
+
);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** Piedritas incrustadas en la cara del corte, para que la tierra no se vea plana. */
|
|
172
|
+
export const Piedras: React.FC<{n?: number; ancho?: number; y0?: number; y1?: number; z: number; color?: string; semilla?: number}> = ({n = 40, ancho = 22, y0 = -0.6, y1 = -6, z, color = '#6B4530', semilla = 0}) => (
|
|
173
|
+
<group>
|
|
174
|
+
{Array.from({length: n}, (_, i) => (
|
|
175
|
+
<mesh key={i} position={[(azarN(semilla + i) - 0.5) * ancho * 0.96, y0 + (y1 - y0) * azarN(semilla + i * 3 + 1), z]} rotation={[0, 0, azarN(semilla + i * 7) * 3]} scale={[1, 0.7, 0.25]}>
|
|
176
|
+
<icosahedronGeometry args={[0.08 + azarN(semilla + i * 5) * 0.14, 0]} />
|
|
177
|
+
<meshStandardMaterial color={color} roughness={1} flatShading />
|
|
178
|
+
</mesh>
|
|
179
|
+
))}
|
|
180
|
+
</group>
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
/** Árbol low-poly: tronco y dos copas facetadas. */
|
|
184
|
+
export const Arbol: React.FC<Pieza & {semilla?: number; color?: string}> = ({semilla = 0, color = '#3F9B3A', ...p}) => {
|
|
185
|
+
const k = 0.85 + azarN(semilla) * 0.4;
|
|
186
|
+
return (
|
|
187
|
+
<group {...grupo(p)}>
|
|
188
|
+
<group scale={k} rotation={[0, azarN(semilla + 1) * 6, 0]}>
|
|
189
|
+
<mesh position={[0, 0.55, 0]} castShadow>
|
|
190
|
+
<cylinderGeometry args={[0.11, 0.17, 1.1, 7]} />
|
|
191
|
+
<meshStandardMaterial color="#7A4E2D" roughness={0.9} flatShading />
|
|
192
|
+
</mesh>
|
|
193
|
+
<mesh position={[0, 1.45, 0]} castShadow>
|
|
194
|
+
<icosahedronGeometry args={[0.72, 0]} />
|
|
195
|
+
<meshStandardMaterial color={color} roughness={0.85} flatShading />
|
|
196
|
+
</mesh>
|
|
197
|
+
<mesh position={[0.28, 1.95, 0.1]} castShadow>
|
|
198
|
+
<icosahedronGeometry args={[0.46, 0]} />
|
|
199
|
+
<meshStandardMaterial color={color} roughness={0.85} flatShading />
|
|
200
|
+
</mesh>
|
|
201
|
+
</group>
|
|
202
|
+
</group>
|
|
203
|
+
);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** Arbusto: una bola facetada aplastada. */
|
|
207
|
+
export const Arbusto: React.FC<Pieza & {color?: string}> = ({color = '#4FAE45', ...p}) => (
|
|
208
|
+
<group {...grupo(p)}>
|
|
209
|
+
<mesh position={[0, 0.3, 0]} scale={[1, 0.7, 1]} castShadow>
|
|
210
|
+
<icosahedronGeometry args={[0.5, 1]} />
|
|
211
|
+
<meshStandardMaterial color={color} roughness={0.9} flatShading />
|
|
212
|
+
</mesh>
|
|
213
|
+
</group>
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
/** Casa sencilla: cuerpo, techo a cuatro aguas, puerta y ventana. Mide ~2.4 de ancho; la base queda en y = 0. */
|
|
217
|
+
export const Casa: React.FC<Pieza & {color?: string; techo?: string}> = ({color = '#F2E6D0', techo = '#D9533B', ...p}) => (
|
|
218
|
+
<group {...grupo(p)}>
|
|
219
|
+
<mesh position={[0, 0.8, 0]} castShadow receiveShadow>
|
|
220
|
+
<boxGeometry args={[2.4, 1.6, 2]} />
|
|
221
|
+
<meshStandardMaterial color={color} roughness={0.85} />
|
|
222
|
+
</mesh>
|
|
223
|
+
<mesh position={[0, 2.05, 0]} rotation={[0, Math.PI / 4, 0]} castShadow>
|
|
224
|
+
<coneGeometry args={[1.95, 0.95, 4]} />
|
|
225
|
+
<meshStandardMaterial color={techo} roughness={0.8} flatShading />
|
|
226
|
+
</mesh>
|
|
227
|
+
<mesh position={[-0.5, 0.5, 1.01]}>
|
|
228
|
+
<boxGeometry args={[0.5, 1, 0.04]} />
|
|
229
|
+
<meshStandardMaterial color="#7A4E2D" roughness={0.8} />
|
|
230
|
+
</mesh>
|
|
231
|
+
<mesh position={[0.55, 0.95, 1.01]}>
|
|
232
|
+
<boxGeometry args={[0.6, 0.5, 0.04]} />
|
|
233
|
+
<meshStandardMaterial color="#9AD4F5" roughness={0.3} />
|
|
234
|
+
</mesh>
|
|
235
|
+
</group>
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Tubo que pasa por `puntos`. `agua` (0..1) lo llena desde el primer punto: anímalo con el tiempo para que el agua avance.
|
|
240
|
+
* El tubo es translúcido para que se vea lo que lleva dentro.
|
|
241
|
+
*/
|
|
242
|
+
export const Tubo: React.FC<{puntos: V3[]; radio?: number; color?: string; agua?: number; colorAgua?: string; opacidad?: number}> = ({puntos, radio = 0.22, color = '#E3E5EA', agua = 0, colorAgua = '#1E8BFF', opacidad = 0.34}) => {
|
|
243
|
+
const llave = JSON.stringify(puntos);
|
|
244
|
+
const SEG = 220;
|
|
245
|
+
const RADIAL = 14;
|
|
246
|
+
const [exterior, interior] = useMemo(() => {
|
|
247
|
+
const curva = new THREE.CatmullRomCurve3(
|
|
248
|
+
puntos.map((p) => new THREE.Vector3(...p)),
|
|
249
|
+
false,
|
|
250
|
+
'catmullrom',
|
|
251
|
+
0.06,
|
|
252
|
+
);
|
|
253
|
+
return [new THREE.TubeGeometry(curva, SEG, radio, RADIAL, false), new THREE.TubeGeometry(curva, SEG, radio * 0.8, RADIAL, false)];
|
|
254
|
+
}, [llave, radio]);
|
|
255
|
+
const k = Math.max(0, Math.min(1, agua));
|
|
256
|
+
useLayoutEffect(() => {
|
|
257
|
+
interior.setDrawRange(0, Math.floor(k * SEG) * RADIAL * 6);
|
|
258
|
+
});
|
|
259
|
+
return (
|
|
260
|
+
<group>
|
|
261
|
+
{k > 0 ? (
|
|
262
|
+
<mesh geometry={interior}>
|
|
263
|
+
<meshStandardMaterial color={colorAgua} roughness={0.25} emissive={colorAgua} emissiveIntensity={0.55} />
|
|
264
|
+
</mesh>
|
|
265
|
+
) : null}
|
|
266
|
+
<mesh geometry={exterior} castShadow>
|
|
267
|
+
<meshStandardMaterial color={color} roughness={0.35} transparent opacity={opacidad} depthWrite={false} />
|
|
268
|
+
</mesh>
|
|
269
|
+
</group>
|
|
270
|
+
);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/** Superficie de agua (río, lago, charco). */
|
|
274
|
+
export const Agua: React.FC<Pieza & {tam: [number, number]; color?: string}> = ({tam, color = '#3AA7FF', ...p}) => (
|
|
275
|
+
<mesh {...grupo(p)} receiveShadow>
|
|
276
|
+
<boxGeometry args={[tam[0], 0.06, tam[1]]} />
|
|
277
|
+
<meshStandardMaterial color={color} roughness={0.15} metalness={0.1} transparent opacity={0.9} />
|
|
278
|
+
</mesh>
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
/** Mesa de madera redonda y grande: el piso de las escenas con foco de luz. La superficie queda en y = 0. */
|
|
282
|
+
export const Mesa: React.FC<{radio?: number; color?: string}> = ({radio = 14, color = '#6B4226'}) => (
|
|
283
|
+
<mesh position={[0, -0.25, 0]} receiveShadow>
|
|
284
|
+
<cylinderGeometry args={[radio, radio, 0.5, 64]} />
|
|
285
|
+
<meshStandardMaterial color={color} roughness={0.62} metalness={0.05} />
|
|
286
|
+
</mesh>
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Pilas de monedas o billetes. `n` = cuántas piezas se ven (anímalo para que la pila crezca); se reparten en
|
|
291
|
+
* columnas de `porColumna`, acomodadas en una rejilla alrededor de `pos`.
|
|
292
|
+
*/
|
|
293
|
+
export const Pila: React.FC<{forma?: 'moneda' | 'billete'; n: number; max?: number; porColumna?: number; pos?: V3; color?: string; separacion?: number; semilla?: number}> = ({forma = 'moneda', n, max = 400, porColumna = 24, pos = [0, 0, 0], color, separacion, semilla = 0}) => {
|
|
294
|
+
const ref = useRef<THREE.InstancedMesh>(null);
|
|
295
|
+
const moneda = forma === 'moneda';
|
|
296
|
+
const alto = moneda ? 0.1 : 0.04;
|
|
297
|
+
const paso = separacion ?? (moneda ? 1.15 : 1.9);
|
|
298
|
+
useLayoutEffect(() => {
|
|
299
|
+
const m = ref.current;
|
|
300
|
+
if (!m) return;
|
|
301
|
+
const cols = Math.ceil(max / porColumna);
|
|
302
|
+
const lado = Math.ceil(Math.sqrt(cols));
|
|
303
|
+
const o = new THREE.Object3D();
|
|
304
|
+
for (let i = 0; i < max; i++) {
|
|
305
|
+
const c = Math.floor(i / porColumna);
|
|
306
|
+
const nivel = i % porColumna;
|
|
307
|
+
// las columnas se llenan del centro hacia afuera
|
|
308
|
+
const cx = (c % lado) - (lado - 1) / 2;
|
|
309
|
+
const cz = Math.floor(c / lado) - (lado - 1) / 2;
|
|
310
|
+
o.position.set(cx * paso + (azarN(semilla + i) - 0.5) * 0.07, alto / 2 + nivel * (alto + 0.004), cz * (moneda ? paso : paso * 0.55) + (azarN(semilla + i * 3) - 0.5) * 0.07);
|
|
311
|
+
o.rotation.set(0, azarN(semilla + i * 7) * (moneda ? 6.28 : 0.16) - (moneda ? 0 : 0.08), 0);
|
|
312
|
+
o.updateMatrix();
|
|
313
|
+
m.setMatrixAt(i, o.matrix);
|
|
314
|
+
}
|
|
315
|
+
m.instanceMatrix.needsUpdate = true;
|
|
316
|
+
}, [max, porColumna, paso, moneda, alto, semilla]);
|
|
317
|
+
useLayoutEffect(() => {
|
|
318
|
+
if (ref.current) ref.current.count = Math.max(0, Math.min(max, Math.floor(n)));
|
|
319
|
+
});
|
|
320
|
+
return (
|
|
321
|
+
<group position={pos}>
|
|
322
|
+
<instancedMesh ref={ref} args={[undefined as any, undefined as any, max]} castShadow receiveShadow frustumCulled={false}>
|
|
323
|
+
{moneda ? <cylinderGeometry args={[0.5, 0.5, alto, 28]} /> : <boxGeometry args={[1.7, alto, 0.8]} />}
|
|
324
|
+
<meshStandardMaterial color={color ?? (moneda ? '#E8B64A' : '#7DBB86')} roughness={moneda ? 0.3 : 0.8} metalness={moneda ? 0.85 : 0.02} />
|
|
325
|
+
</instancedMesh>
|
|
326
|
+
</group>
|
|
327
|
+
);
|
|
328
|
+
};
|
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
|
);
|