morfeo 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,130 @@
1
+ // Piezas 3D de casa y de electrónica, sacadas de los mundos que más se repetían en videos reales (consola, escalada,
2
+ // control): chip con patitas, ventilador de pie y de PC, escritorio, puerta, audífonos, nube de polvo o humo.
3
+ // Todas dependen solo de props y de `t` (sin useFrame). Las nubes se apagan solas fuera de su tramo (`en`..`hasta`).
4
+ //
5
+ // <Chip pos={[0, 0.62, 0]} tam={[0.7, 0.7]} calor={0.5} /> <Ventilador pos={[1.7, 0, -1.6]} t={t} />
6
+ // <Nube3D pos={[0.7, 0.9, -2]} t={t} en={12} hasta={16} />
7
+ import React from 'react';
8
+ import {azarN} from './ilustrado';
9
+ import {Bloque, grupo, type Pieza, type V3} from './tres';
10
+
11
+ /** Chip negro con patitas plateadas a los lados. `calor` 0..1 lo pone al rojo. */
12
+ export const Chip: React.FC<Pieza & {tam?: [number, number]; calor?: number; color?: string}> = ({tam = [0.6, 0.6], calor = 0, color = '#17171C', ...p}) => (
13
+ <group {...grupo(p)}>
14
+ <mesh position={[0, 0.05, 0]} castShadow>
15
+ <boxGeometry args={[tam[0], 0.1, tam[1]]} />
16
+ <meshStandardMaterial color={color} roughness={0.6} emissive="#FF4A1C" emissiveIntensity={calor * 0.9} />
17
+ </mesh>
18
+ {Array.from({length: 6}, (_, i) =>
19
+ [-1, 1].map((l) => <Bloque key={`${i}${l}`} pos={[-tam[0] / 2 + 0.08 + (i * (tam[0] - 0.16)) / 5, 0.02, l * (tam[1] / 2 + 0.03)]} tam={[0.04, 0.03, 0.06]} color="#C9CCD3" metal={0.8} rugosidad={0.3} />),
20
+ )}
21
+ </group>
22
+ );
23
+
24
+ /** Aspas que giran en el plano XY (miran hacia +z). */
25
+ export const Aspas: React.FC<{giro: number; radio: number; n?: number; color?: string}> = ({giro, radio, n = 4, color = '#E8ECF2'}) => (
26
+ <group rotation={[0, 0, giro]}>
27
+ {Array.from({length: n}, (_, k) => (
28
+ <Bloque key={k} pos={[(radio / 2) * Math.cos((k * Math.PI * 2) / n), (radio / 2) * Math.sin((k * Math.PI * 2) / n), 0]} rot={[0, 0, (k * 360) / n]} tam={[radio, radio * 0.32, 0.02]} color={color} />
29
+ ))}
30
+ </group>
31
+ );
32
+
33
+ /**
34
+ * Ventilador. `tipo: 'pie'` (de pedestal, ~1.3 u, mira hacia +z; gíralo con `rot`) o `'pc'` (disipador con
35
+ * ventilador encima, para chips). `velocidad` en vueltas por segundo.
36
+ */
37
+ export const Ventilador: React.FC<Pieza & {t: number; tipo?: 'pie' | 'pc'; velocidad?: number; color?: string}> = ({t, tipo = 'pie', velocidad = 2.2, color = '#D9DDE3', ...p}) => {
38
+ const giro = t * velocidad * Math.PI * 2;
39
+ if (tipo === 'pc')
40
+ return (
41
+ <group {...grupo(p)}>
42
+ {Array.from({length: 7}, (_, i) => (
43
+ <Bloque key={i} pos={[-0.3 + i * 0.1, 0.12, 0]} tam={[0.03, 0.24, 0.7]} color="#B9BEC8" metal={0.7} rugosidad={0.35} />
44
+ ))}
45
+ <group position={[0, 0.3, 0]}>
46
+ <mesh>
47
+ <cylinderGeometry args={[0.34, 0.34, 0.06, 24]} />
48
+ <meshStandardMaterial color="#222228" />
49
+ </mesh>
50
+ <group position={[0, 0.04, 0]} rotation={[-Math.PI / 2, 0, 0]}>
51
+ <Aspas giro={giro} radio={0.3} n={5} color="#3A3A42" />
52
+ </group>
53
+ </group>
54
+ </group>
55
+ );
56
+ return (
57
+ <group {...grupo(p)}>
58
+ <Bloque pos={[0, 0.03, 0]} tam={[0.5, 0.06, 0.5]} color={color} radio={0.05} />
59
+ <Bloque pos={[0, 0.6, 0]} tam={[0.05, 1.1, 0.05]} color="#B9BEC8" metal={0.5} />
60
+ <group position={[0, 1.2, 0]}>
61
+ <mesh rotation={[Math.PI / 2, 0, 0]}>
62
+ <torusGeometry args={[0.3, 0.025, 8, 28]} />
63
+ <meshStandardMaterial color={color} />
64
+ </mesh>
65
+ <Aspas giro={giro} radio={0.26} color="#7FB2FF" />
66
+ </group>
67
+ </group>
68
+ );
69
+ };
70
+
71
+ /** Escritorio de madera (cara de arriba a `alto`). */
72
+ export const Escritorio: React.FC<Pieza & {ancho?: number; fondo?: number; alto?: number; color?: string; patas?: string}> = ({ancho = 2.4, fondo = 0.9, alto = 0.78, color = '#C49A6C', patas = '#8A6A48', ...p}) => (
73
+ <group {...grupo(p)}>
74
+ <Bloque pos={[0, alto - 0.035, 0]} tam={[ancho, 0.07, fondo]} color={color} rugosidad={0.8} />
75
+ {[-1, 1].map((l) => (
76
+ <Bloque key={l} pos={[l * (ancho / 2 - 0.1), (alto - 0.07) / 2, 0]} tam={[0.07, alto - 0.07, fondo - 0.1]} color={patas} />
77
+ ))}
78
+ </group>
79
+ );
80
+
81
+ /** Puerta en una pared (mira hacia +z; para la pared izquierda usa rot [0, 90, 0]). `abierta` 0..1. */
82
+ export const Puerta: React.FC<Pieza & {abierta?: number; color?: string; marco?: string}> = ({abierta = 0, color = '#8A6A48', marco = '#E9E1D2', ...p}) => (
83
+ <group {...grupo(p)}>
84
+ <Bloque pos={[0, 1.07, 0]} tam={[1.14, 2.2, 0.04]} color={marco} />
85
+ <group position={[-0.5, 0, 0.04]} rotation={[0, -abierta * 1.4, 0]}>
86
+ <Bloque pos={[0.5, 1.03, 0]} tam={[1.0, 2.06, 0.05]} color={color} />
87
+ <Bloque pos={[0.88, 1.0, 0.04]} tam={[0.12, 0.05, 0.05]} color="#D4B04A" metal={0.6} />
88
+ </group>
89
+ </group>
90
+ );
91
+
92
+ /** Audífonos de diadema (para la cabeza de una Persona3D: centro de la cabeza, escala ~1.2). */
93
+ export const Audifonos: React.FC<Pieza & {color?: string; diadema?: string}> = ({color = '#E5484D', diadema = '#1B1B20', ...p}) => (
94
+ <group {...grupo(p)}>
95
+ <mesh>
96
+ <torusGeometry args={[0.36, 0.035, 8, 24, Math.PI]} />
97
+ <meshStandardMaterial color={diadema} />
98
+ </mesh>
99
+ {[-1, 1].map((l) => (
100
+ <mesh key={l} position={[l * 0.37, -0.02, 0]} rotation={[0, 0, Math.PI / 2]}>
101
+ <cylinderGeometry args={[0.14, 0.14, 0.1, 18]} />
102
+ <meshStandardMaterial color={color} />
103
+ </mesh>
104
+ ))}
105
+ </group>
106
+ );
107
+
108
+ /**
109
+ * Nube que sale de un punto (polvo, humo, vapor): crece en ~2 s desde `en` y desaparece en `hasta` (segundos del
110
+ * video). Fuera de ese tramo no dibuja nada, así que no se queda en el cierre.
111
+ */
112
+ export const Nube3D: React.FC<{pos: V3; t: number; en: number; hasta?: number; n?: number; color?: string; tam?: number; alcance?: number}> = ({pos, t, en, hasta, n = 40, color = '#9A948A', tam = 0.03, alcance = 0.8}) => {
113
+ if (t < en || (hasta != null && t > hasta)) return null;
114
+ const k = Math.min(1, (t - en) / 2.2);
115
+ const sale = hasta != null ? Math.min(1, Math.max(0, (hasta - t) / 0.5)) : 1;
116
+ return (
117
+ <group>
118
+ {Array.from({length: n}, (_, i) => {
119
+ const dir = azarN(i * 3.3) * Math.PI * 2;
120
+ const r = k * (0.3 + azarN(i * 5.1) * 0.6) * alcance;
121
+ return (
122
+ <mesh key={i} position={[pos[0] + Math.cos(dir) * r, pos[1] + k * (0.2 + azarN(i * 1.7) * 0.5) * alcance, pos[2] + Math.sin(dir) * r * 0.6]}>
123
+ <sphereGeometry args={[tam * (0.7 + azarN(i * 9.1) * 1.2), 6, 5]} />
124
+ <meshStandardMaterial color={color} transparent opacity={0.75 * sale} roughness={1} />
125
+ </mesh>
126
+ );
127
+ })}
128
+ </group>
129
+ );
130
+ };
@@ -3,6 +3,7 @@
3
3
  // import {Persona2D, Multitud, Globo, CiudadNoche, Cuarto} from 'morfeo/kit'; (personajes y escenarios 2D)
4
4
  // import {Lienzo3D, Persona3D, Ciudad, Terreno, Tubo, Mesa, Pila} from 'morfeo/kit'; (3D: de día o con luz cálida)
5
5
  // import {Maqueta, Modelo, Persona, Coche, Trafico, Corte} from 'morfeo/kit'; (diorama low-poly)
6
+ // import {Ambiente, Tele3D, Gato3D} from 'morfeo/kit'; (ambientes 3D completos: cuarto-gamer, sala, arcade)
6
7
  // import {GraficaBarras, GraficaLinea, Comparacion, Lupa} from 'morfeo/kit'; (datos 2D)
7
8
  export * from './ilustrado';
8
9
  export * from './personas';
@@ -13,3 +14,5 @@ export * from './datos';
13
14
  export * from './diorama';
14
15
  export {clamp, easeIO, easeOut, easeOutExpo, backOut, resorte, ruido, lerp, mezclaColor} from '../estilo';
15
16
  export * from './armar';
17
+ export * from './ambientes';
18
+ export * from './casa';
@@ -6,7 +6,9 @@
6
6
  // La cámara se mueve por `tomas`: una por escena; viaja desde donde terminó la anterior y luego deriva despacio.
7
7
  // Nada usa useFrame: todo depende de las props (`t`, `tEscena`), como pide Remotion.
8
8
  import {useThree} from '@react-three/fiber';
9
+ import {Bloom, DepthOfField, EffectComposer, TiltShift2, Vignette} from '@react-three/postprocessing';
9
10
  import {ThreeCanvas} from '@remotion/three';
11
+ import {continueRender, delayRender} from 'remotion';
10
12
  import React, {useEffect, useLayoutEffect, useMemo, useRef} from 'react';
11
13
  import * as THREE from 'three';
12
14
  import {RoomEnvironment} from 'three/examples/jsm/environments/RoomEnvironment.js';
@@ -69,10 +71,63 @@ export function camara3D(tomas: (Toma3D | undefined | null)[], escena: number, t
69
71
  return {pos: mezcla(o.pos, actual.pos, kv), mirar: mezcla(o.mirar, actual.mirar, kv), fov: o.fov + (actual.fov - o.fov) * kv};
70
72
  }
71
73
 
72
- const Camara: React.FC<{estado: Estado}> = ({estado}) => {
73
- const {camera} = useThree();
74
+ /**
75
+ * Toma calculada para que un objeto de tamaño `tam` (u) quepa en el cuadro vertical sin pegarse ni salirse.
76
+ * tipo: 'detalle' (llena el ancho) · 'media' (con aire alrededor) · 'general' (con su entorno)
77
+ * desde: 'frente' | 'izq' | 'der' | 'arriba' | 'atras' o un vector propio (de dónde se mira)
78
+ * La toma se acerca un poco durante la escena (`acercar`, 0.08 = 8 %).
79
+ */
80
+ export function tomaA(objetivo: V3, o: {tipo?: 'detalle' | 'media' | 'general'; tam?: number; desde?: 'frente' | 'izq' | 'der' | 'arriba' | 'atras' | V3; fov?: number; acercar?: number; corte?: boolean} = {}): Toma3D {
81
+ const fov = o.fov ?? 40;
82
+ const DIR: Record<string, V3> = {frente: [0, 0.35, 1], izq: [-1, 0.35, 0.35], der: [1, 0.35, 0.35], arriba: [0, 1.7, 0.55], atras: [0, 0.35, -1]};
83
+ const d0 = Array.isArray(o.desde) ? o.desde : DIR[o.desde ?? 'frente'];
84
+ const n = Math.hypot(...d0) || 1;
85
+ const dir: V3 = [d0[0] / n, d0[1] / n, d0[2] / n];
86
+ // en vertical el ancho manda: campo horizontal = 2·atan(tan(fov/2)·9/16)
87
+ const mitadH = Math.atan(Math.tan(((fov / 2) * Math.PI) / 180) * (9 / 16));
88
+ const ancho = (o.tam ?? 1) * {detalle: 1.35, media: 2.4, general: 4.5}[o.tipo ?? 'media'];
89
+ const d = ancho / (2 * Math.tan(mitadH));
90
+ const pos: V3 = [objetivo[0] + dir[0] * d, objetivo[1] + dir[1] * d, objetivo[2] + dir[2] * d];
91
+ return {pos, mirar: objetivo, fov, corte: o.corte, a: {pos: mezcla(pos, objetivo, o.acercar ?? 0.08)}};
92
+ }
93
+
94
+ /**
95
+ * Lente despejado: si algo queda pegado a la cámara (a menos de `cerca` u) entre ella y lo que mira, la cámara avanza
96
+ * hasta pasarlo. Evita los cuadros tapados por una cabeza, una bocina o una pared en primer plano.
97
+ */
98
+ function despejar(scene: THREE.Scene, desde: THREE.Vector3, hacia: THREE.Vector3, cerca = 0.9): THREE.Vector3 | null {
99
+ const dir = hacia.clone().sub(desde);
100
+ const dist = dir.length();
101
+ if (dist < 0.5) return null;
102
+ dir.normalize();
103
+ const solido = (h: THREE.Intersection) => {
104
+ const m = h.object as THREE.Mesh;
105
+ if (!(m as any).isMesh || !m.visible || m.userData?.sinOclusion) return false;
106
+ const mat = (Array.isArray(m.material) ? m.material[0] : m.material) as THREE.Material | undefined;
107
+ return !!mat && !(mat.transparent && (mat as any).opacity < 0.6);
108
+ };
109
+ scene.updateMatrixWorld(true);
110
+ const ray = new THREE.Raycaster(desde, dir, 0.02, Math.min(cerca, dist - 0.4));
111
+ if (!ray.intersectObjects(scene.children, true).some(solido)) return null;
112
+ // la cara de esos objetos que mira hacia el objetivo: desde ahí ya no tapan
113
+ const vuelta = new THREE.Raycaster(hacia, dir.clone().negate(), 0, dist);
114
+ let salida = 0;
115
+ for (const h of vuelta.intersectObjects(scene.children, true)) {
116
+ const desdeCam = dist - h.distance;
117
+ if (solido(h) && desdeCam < cerca + 1.2) salida = Math.max(salida, desdeCam);
118
+ }
119
+ if (!salida || salida > dist - 0.4) return null;
120
+ return desde.clone().add(dir.multiplyScalar(salida + 0.12));
121
+ }
122
+
123
+ const Camara: React.FC<{estado: Estado; despejado?: boolean}> = ({estado, despejado}) => {
124
+ const {camera, scene} = useThree();
74
125
  useLayoutEffect(() => {
75
126
  camera.position.set(...estado.pos);
127
+ if (despejado) {
128
+ const nueva = despejar(scene, new THREE.Vector3(...estado.pos), new THREE.Vector3(...estado.mirar));
129
+ if (nueva) camera.position.copy(nueva);
130
+ }
76
131
  camera.lookAt(...estado.mirar);
77
132
  const p = camera as THREE.PerspectiveCamera;
78
133
  if (p.isPerspectiveCamera) {
@@ -139,13 +194,14 @@ const Recorte: React.FC = () => {
139
194
 
140
195
  /**
141
196
  * Etiqueta pegada a un punto 3D: pastilla de color con una flecha que apunta al objeto. Sigue al objeto cuando la
142
- * cámara se mueve. `escena` (1, 2...) la limita a esa escena; `en`/`hasta` son segundos desde que empieza.
197
+ * cámara se mueve. `escena` (1, 2...) la limita a esa escena; `en`/`hasta` son segundos desde que empieza, o una
198
+ * palabra (`en: 'memoria'`) si el Lienzo3D recibe `cuando`: así sale junto con el objeto que llega en esa palabra.
143
199
  */
144
200
  export type Etiqueta3D = {
145
201
  pos: V3;
146
202
  texto: string;
147
203
  escena?: number | number[];
148
- en?: number;
204
+ en?: number | string;
149
205
  hasta?: number;
150
206
  color?: string;
151
207
  colorTexto?: string;
@@ -157,12 +213,12 @@ export type Etiqueta3D = {
157
213
  inclinar?: number;
158
214
  };
159
215
 
160
- const EtiquetasPantalla: React.FC<{etiquetas: Etiqueta3D[]; estado: Estado; escena: number; tEscena: number}> = ({etiquetas, estado, escena, tEscena}) => (
216
+ const EtiquetasPantalla: React.FC<{etiquetas: Etiqueta3D[]; estado: Estado; escena: number; tEscena: number; enDe: (en: Etiqueta3D['en']) => number}> = ({etiquetas, estado, escena, tEscena, enDe}) => (
161
217
  <>
162
218
  {etiquetas.map((e, i) => {
163
219
  const escenas = e.escena == null ? null : ([] as number[]).concat(e.escena);
164
220
  if (escenas && !escenas.includes(escena)) return null;
165
- const t = tEscena - (e.en ?? 0.2);
221
+ const t = tEscena - enDe(e.en);
166
222
  if (t < 0 || (e.hasta != null && tEscena > e.hasta)) return null;
167
223
  const p = proyectar(estado, e.pos);
168
224
  if (!p.visible) return null;
@@ -195,20 +251,87 @@ const EtiquetasPantalla: React.FC<{etiquetas: Etiqueta3D[]; estado: Estado; esce
195
251
  </>
196
252
  );
197
253
 
254
+ /**
255
+ * Postproceso del lienzo (todo determinista: sin ruido ni nada que dependa del reloj).
256
+ * 'cine' foco en lo que mira la toma (el fondo se desenfoca), brillo en pantallas y luces, viñeta
257
+ * 'miniatura' desenfoque arriba y abajo (efecto maqueta / tilt-shift), brillo y viñeta
258
+ * 'suave' solo brillo y viñeta
259
+ * Con objeto ajustas cada efecto: {foco: true | número (rango de foco), brillo: 0..2, vinieta: 0..1, miniatura: 0..1}.
260
+ */
261
+ export type Post3D = 'cine' | 'miniatura' | 'suave' | false | {foco?: boolean | number; brillo?: number; vinieta?: number; miniatura?: number};
262
+ const PRESETS_POST: Record<string, {foco?: boolean | number; brillo?: number; vinieta?: number; miniatura?: number}> = {
263
+ cine: {foco: true, brillo: 0.35, vinieta: 0.5},
264
+ miniatura: {miniatura: 0.55, brillo: 0.3, vinieta: 0.45},
265
+ suave: {brillo: 0.3, vinieta: 0.45},
266
+ };
267
+ const Post: React.FC<{post: Post3D; estado: Estado}> = ({post, estado}) => {
268
+ if (!post) return null;
269
+ const o = typeof post === 'string' ? PRESETS_POST[post] ?? {} : post;
270
+ const dist = Math.hypot(estado.pos[0] - estado.mirar[0], estado.pos[1] - estado.mirar[1], estado.pos[2] - estado.mirar[2]);
271
+ const rango = typeof o.foco === 'number' ? o.foco : Math.max(1.2, dist * 0.55);
272
+ const efectos: React.ReactElement[] = [];
273
+ if (o.foco) efectos.push(<DepthOfField key="f" target={estado.mirar} worldFocusRange={rango} bokehScale={4} resolutionScale={0.6} />);
274
+ if (o.miniatura) efectos.push(<TiltShift2 key="m" blur={0.12 * o.miniatura} taper={0.6} start={[0.5, 0.0]} end={[0.5, 1.0]} />);
275
+ if (o.brillo) efectos.push(<Bloom key="b" intensity={o.brillo} luminanceThreshold={0.85} luminanceSmoothing={0.2} mipmapBlur />);
276
+ if (o.vinieta) efectos.push(<Vignette key="v" offset={0.3} darkness={o.vinieta} />);
277
+ if (!efectos.length) return null;
278
+ return (
279
+ <>
280
+ <EffectComposer multisampling={4}>{efectos}</EffectComposer>
281
+ <RedibujarAlMontar />
282
+ </>
283
+ );
284
+ };
285
+
286
+ /**
287
+ * Remotion dibuja el primer cuadro de cada pestaña antes de que el compositor de efectos termine de montarse (sale
288
+ * negro: 1 cuadro por pestaña al inicio del video). Este componente detiene la captura (delayRender) hasta que el
289
+ * compositor ya dibujó dos veces con sus efectos montados; solo pasa al montar, no en cada cuadro.
290
+ */
291
+ const RedibujarAlMontar: React.FC = () => {
292
+ const {advance} = useThree();
293
+ const [espera] = React.useState(() => delayRender('Esperando el postproceso 3D'));
294
+ useEffect(() => {
295
+ let vivo = true;
296
+ const r1 = requestAnimationFrame(() => {
297
+ if (!vivo) return;
298
+ advance(performance.now());
299
+ requestAnimationFrame(() => {
300
+ if (!vivo) return;
301
+ advance(performance.now());
302
+ continueRender(espera);
303
+ });
304
+ });
305
+ return () => {
306
+ vivo = false;
307
+ cancelAnimationFrame(r1);
308
+ continueRender(espera);
309
+ };
310
+ }, [advance, espera]);
311
+ return null;
312
+ };
313
+
198
314
  /**
199
315
  * El lienzo del mundo 3D (1080×1920). `luz`:
200
316
  * 'dia' cielo claro, sol con sombras suaves (maquetas, cortes de terreno, low-poly)
201
317
  * 'calida' cuarto oscuro con un foco cálido desde arriba y su cono visible (mesa, dinero, objetos)
202
318
  * `hora` (0–24 o 'atardecer', 'noche'...) cambia el cielo y el sol de la luz 'dia'; anímala para pasar de día a noche.
203
319
  * `etiquetas`: pastillas con flecha pegadas a puntos 3D (siguen al objeto con la cámara).
320
+ * 'noche' interior de noche: luz ambiente tenue y azulada; las luces las pone el set (lámpara, tele, neón)
321
+ * `post`: postproceso ('cine', 'miniatura', 'suave'); ver Post3D.
204
322
  */
205
323
  export const Lienzo3D: React.FC<{
206
324
  tomas: (Toma3D | undefined | null)[];
207
325
  escena?: number;
208
326
  tEscena?: number;
209
327
  inicios?: number[];
210
- luz?: 'dia' | 'calida';
328
+ luz?: 'dia' | 'calida' | 'noche';
211
329
  hora?: number | string;
330
+ post?: Post3D;
331
+ /** La cámara avanza si algo le tapa el lente (cabeza, pared, objeto en primer plano). */
332
+ despejado?: boolean;
333
+ /** El `cuando` del componente: deja usar palabras en `en` de las etiquetas (sale cuando llega su objeto). */
334
+ cuando?: (en: unknown) => number;
212
335
  fondo?: string;
213
336
  niebla?: [number, number];
214
337
  sol?: V3;
@@ -217,17 +340,17 @@ export const Lienzo3D: React.FC<{
217
340
  /** Encima del 3D, en coordenadas de pantalla (texto, tarjetas). */
218
341
  encima?: React.ReactNode;
219
342
  children: React.ReactNode;
220
- }> = ({tomas, escena = 1, tEscena = 0, inicios, luz = 'dia', hora, fondo, niebla, sol = [9, 15, 8], foco = [0, 10, 1.5], etiquetas, encima, children}) => {
343
+ }> = ({tomas, escena = 1, tEscena = 0, inicios, luz = 'dia', hora, fondo, niebla, sol = [9, 15, 8], foco = [0, 10, 1.5], etiquetas, encima, post = false, despejado = false, cuando, children}) => {
221
344
  const estado = camara3D(tomas, escena, tEscena, inicios);
222
345
  const cielo = cieloDeHora(hora ?? 12);
223
- const color = fondo ?? (luz === 'dia' ? cielo.fondo : '#120A06');
346
+ const color = fondo ?? (luz === 'dia' ? cielo.fondo : luz === 'noche' ? '#0B0E1A' : '#120A06');
224
347
  const posSol: V3 = [sol[0], sol[1] * cielo.altura + 1.5, sol[2]];
225
348
  return (
226
349
  <div style={{position: 'relative', width: 1080, height: 1920}}>
227
350
  <ThreeCanvas width={1080} height={1920} shadows gl={{antialias: true}} camera={{fov: estado.fov, position: estado.pos, near: 0.1, far: 400}}>
228
351
  <color attach="background" args={[color]} />
229
352
  {niebla ? <fog attach="fog" args={[color, niebla[0], niebla[1]]} /> : null}
230
- <Camara estado={estado} />
353
+ <Camara estado={estado} despejado={despejado} />
231
354
  <Recorte />
232
355
  {luz === 'dia' ? (
233
356
  <>
@@ -235,6 +358,12 @@ export const Lienzo3D: React.FC<{
235
358
  <hemisphereLight args={[cielo.arriba, cielo.abajo, cielo.fuerzaHemi]} />
236
359
  <directionalLight position={posSol} color={cielo.sol} intensity={cielo.fuerzaSol} 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} />
237
360
  </>
361
+ ) : luz === 'noche' ? (
362
+ <>
363
+ <Entorno fuerza={0.12} />
364
+ <ambientLight color="#3A4470" intensity={0.55} />
365
+ <hemisphereLight args={['#6A78B8', '#1A1424', 0.35]} />
366
+ </>
238
367
  ) : (
239
368
  <>
240
369
  <Entorno fuerza={0.22} />
@@ -248,8 +377,18 @@ export const Lienzo3D: React.FC<{
248
377
  </>
249
378
  )}
250
379
  {children}
380
+ <Post post={post} estado={estado} />
251
381
  </ThreeCanvas>
252
- {etiquetas?.length ? <EtiquetasPantalla etiquetas={etiquetas} estado={estado} escena={escena} tEscena={tEscena} /> : null}
382
+ {etiquetas?.length ? (
383
+ <EtiquetasPantalla
384
+ etiquetas={etiquetas}
385
+ estado={estado}
386
+ escena={escena}
387
+ tEscena={tEscena}
388
+ // palabra → segundos dentro de la escena (+0.35: el objeto ya cayó cuando sale la pastilla)
389
+ enDe={(en) => (typeof en === 'string' && cuando ? cuando(en) + 0.35 - (inicios?.[escena - 1] ?? 0) : typeof en === 'number' ? en : 0.2)}
390
+ />
391
+ ) : null}
253
392
  {encima}
254
393
  </div>
255
394
  );
@@ -10,7 +10,7 @@ import {FondoTema, Post, Textura, Vineta} from './fondo';
10
10
  import {capasDe} from './plantillas';
11
11
  import {Subtitulos} from './subtitulos';
12
12
  import {mezclar, temaDe, type Tema} from './tema';
13
- import {azar, cuando, partesResaltadas, type CtxEscena} from './util';
13
+ import {azar, cuando, norm, partesResaltadas, type CtxEscena} from './util';
14
14
 
15
15
  const TRANSICION = 0.35;
16
16
  // estilos con fondo claro (cielo, papel): los modos de imagen invierten para que el fondo quede oscuro
@@ -105,13 +105,42 @@ export function camaraMundo(d: Datos, si: number, t: number): Toma {
105
105
  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)};
106
106
  }
107
107
 
108
+ /**
109
+ * Cortes de cámara dentro de una escena (segundos): en la primera palabra clave, si la escena dura más de 3,5 s y no
110
+ * fija `plano`; en escenas de más de 7 s sin claves, a la mitad. `cortes: false` en la escena los apaga; `cortes: [2.1]` los fija.
111
+ */
112
+ function cortesDe(e: Escena): number[] {
113
+ if (e.p.cortes === false || e.tipo === 'cta') return [];
114
+ if (Array.isArray(e.p.cortes)) return e.p.cortes.filter((c: unknown) => typeof c === 'number');
115
+ if (e.p.plano || e.dur < 3.5) return [];
116
+ const claves: string[] = (e.p.claves ?? []).map((c: string) => norm(c)).filter(Boolean);
117
+ const ok = (c: number) => c > 1.4 && c < e.dur - 1.1;
118
+ for (const [w, a] of e.palabras ?? []) if (claves.some((c) => norm(w).startsWith(c)) && ok(a)) return [a];
119
+ return e.dur > 7 ? [e.dur / 2] : [];
120
+ }
121
+
122
+ /**
123
+ * Golpe de cámara (énfasis): cuando cae un rótulo `sello` o `pixel`, todo el cuadro da un pequeño zoom con sacudida
124
+ * que se apaga en ~0,35 s. `golpe: false` en la escena (o `tema.movimiento.golpes: false`) lo quita.
125
+ */
126
+ function golpeDe(e: Escena, t: number, tema: Tema): string | undefined {
127
+ const est = e.p.rotuloEstilo;
128
+ if (!e.p.rotulo || e.p.golpe === false || (tema.movimiento as any)?.golpes === false || (est !== 'sello' && est !== 'pixel' && e.p.golpe !== true)) return undefined;
129
+ const ctx: CtxEscena = {t, dur: e.dur, habla: e.habla, palabras: e.palabras ?? [], tema, semilla: ''};
130
+ const t0 = cuando(e.p.rotuloEn, ctx, 0.12) + (est === 'sello' ? 0.12 : 0);
131
+ const x = t - t0;
132
+ if (x < 0 || x > 0.5) return undefined;
133
+ const k = Math.exp(-x * 9);
134
+ return `scale(${1 + 0.045 * k}) translate(${Math.sin(x * 70) * 9 * k}px, ${Math.cos(x * 55) * 6 * k}px)`;
135
+ }
136
+
108
137
  /** 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í. */
109
138
  const Mundo: React.FC<{d: Datos; si: number; t: number; T: number; tema: Tema}> = ({d, si, t, T, tema}) => {
110
139
  const marca = useMarca();
111
140
  const e = d.escenas[si];
112
141
  const cam = camaraMundo(d, si, t);
113
142
  const palabras = d.escenas.flatMap((x) => (x.palabras ?? []).map(([w, a, b]) => [w, a + x.t0, b + x.t0] as [string, number, number]));
114
- 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};
143
+ 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, escenasInfo: d.escenas.map((x) => ({say: x.p.say, plano: x.p.plano, capas: x.tipo === 'cta' || (x.p.capas ?? []).some((c: any) => !c.enMundo), cortes: cortesDe(x)}))};
115
144
  const temaEsc = e.p.tema ? mezclar(tema, e.p.tema) : tema;
116
145
  const ctxEsc: CtxEscena = {t, dur: e.dur, habla: e.habla, palabras: e.palabras ?? [], tema: temaEsc, semilla: `${d.id}-${e.t0}`};
117
146
  const propias = capasDe(e.tipo, e.p, temaEsc, marca).filter((c: any) => c.enMundo);
@@ -432,6 +461,7 @@ export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string,
432
461
  }
433
462
  return (
434
463
  <AbsoluteFill style={{background: '#000', overflow: 'hidden'}}>
464
+ <AbsoluteFill style={{transform: golpeDe(e, t, tema)}}>
435
465
  <Modo d={d} si={si} t={t} T={T} claro={LOOKS_CLAROS.includes(d.estilo ?? '') || tema.colores.texto !== '#FFFFFF'}>
436
466
  {conMundo && enTransicion && prev ? (
437
467
  <AbsoluteFill style={estiloPrevio}>
@@ -452,6 +482,7 @@ export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string,
452
482
  <EscenaVista e={e} t={t} tema={tema} semilla={semilla} sinFondo={conMundo} />
453
483
  </AbsoluteFill>
454
484
  </Modo>
485
+ </AbsoluteFill>
455
486
  {destello > 0 ? <AbsoluteFill style={{background: '#fff', opacity: destello}} /> : null}
456
487
  <Post tema={tema} />
457
488
  {/* 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 */}
@@ -12,6 +12,7 @@ import {Particulas} from './fondo';
12
12
  import {Escena3D, Modelo3D} from './modelo3d';
13
13
  import {Escenario} from './escenarios';
14
14
  import {componente, nombresComponentes} from './registro';
15
+ import {Ambiente, reaccionesAuto} from '../kit/ambientes';
15
16
  import {Barra, Chat, Estado, Globo, ImagenCambia, Medidor, Noticia, Onda, Seguir, Votacion} from './widgets';
16
17
 
17
18
  export type Capa = Record<string, any> & {tipo: string};
@@ -272,6 +273,29 @@ const Contenido: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
272
273
  return <Seguir c={c} ctx={ctx} />;
273
274
  case 'modelo':
274
275
  return <Modelo3D c={c} ctx={ctx} />;
276
+ case 'ambiente': {
277
+ const t0 = cuando(c.en, ctx, 0);
278
+ const info = ctx.escenasInfo;
279
+ const enMundo = ctx.escena != null && !!info;
280
+ const reacciones = c.reacciones ?? (c.persona && info && c.reaccionar !== false ? reaccionesAuto(info, ctx.inicios ?? []) : []);
281
+ return (
282
+ <Ambiente
283
+ nombre={c.nombre}
284
+ t={ctx.t - t0}
285
+ escena={enMundo ? ctx.escena : 1}
286
+ tEscena={enMundo ? ctx.tEscena : ctx.t}
287
+ inicios={enMundo ? ctx.inicios : [0]}
288
+ planos={c.planos ?? (enMundo ? info!.map((e) => e.plano as any) : [c.plano])}
289
+ conCapas={enMundo ? info!.map((e) => !!e.capas) : []}
290
+ cortes={enMundo && c.cortes !== false ? info!.map((e) => e.cortes ?? []) : []}
291
+ persona={c.persona}
292
+ reacciones={reacciones.map((r: any) => ({...r, en: r.en - t0}))}
293
+ post={c.post}
294
+ etiquetas={c.etiquetas}
295
+ cuando={(en: unknown) => cuando(en, ctx) - t0}
296
+ />
297
+ );
298
+ }
275
299
  case 'componente': {
276
300
  const Comp = componente(c.nombre);
277
301
  if (!Comp) throw new Error(`No hay un componente llamado "${c.nombre}". Regístralo en morfeo.config.mjs → componentes. Disponibles: ${nombresComponentes().join(', ') || 'ninguno'}`);
@@ -13,9 +13,10 @@ export type CtxEscena = {
13
13
  escena?: number; // en el `mundo`: número de la escena actual (1 = la primera)
14
14
  camara?: {x: number; y: number; zoom: number; giro: number}; // en el `mundo`: la cámara de este cuadro (para paralaje)
15
15
  tEscena?: number; // en el `mundo`: segundos desde que empezó la escena actual
16
+ escenasInfo?: {say?: string; plano?: unknown; capas?: boolean; cortes?: number[]}[]; // en el `mundo`: lo que dice cada escena y su `plano` (ambientes)
16
17
  };
17
18
 
18
- const norm = (s: string) =>
19
+ export const norm = (s: string) =>
19
20
  s
20
21
  .normalize('NFD')
21
22
  .replace(/[̀-ͯ]/g, '')