morfeo 0.6.0 → 0.6.2

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/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Tipos de Morfeo: dan autocompletado en morfeo.config.mjs y videos.mjs (también en JS).
2
2
 
3
3
  /** '#RRGGBB' o [r, g, b] */
4
- export type Ambiente = 'cuarto-gamer' | 'sala' | 'arcade';
4
+ export type Ambiente = 'cuarto-gamer' | 'sala' | 'arcade' | 'taller' | 'oficina';
5
5
  /** Postproceso 3D: 'cine' (foco + brillo + viñeta), 'miniatura' (tilt-shift), 'suave', false o ajustes propios. */
6
6
  export type Post3D = 'cine' | 'miniatura' | 'suave' | false | {foco?: boolean | number; brillo?: number; vinieta?: number; miniatura?: number};
7
7
  export type Color = string | [number, number, number];
@@ -87,7 +87,7 @@ export type Capa = CapaBase &
87
87
  | {tipo: 'fuente'; texto: string; etiqueta?: string; tam?: number; color?: string; colorTexto?: string}
88
88
  | {tipo: 'seccion'; texto: string; tam?: number; color?: string}
89
89
  | {tipo: 'insignia'; texto: string | number; tam?: number; fondo?: string; colorTexto?: string; inclinar?: number}
90
- | {tipo: 'sello'; texto: string; tam?: number; color?: string; inclinar?: number}
90
+ | {tipo: 'sello'; texto: string; tam?: number; color?: string; inclinar?: number; /** caja detrás (sobre fondos claros) */ fondo?: string}
91
91
  | {tipo: 'terminal'; lineas: (string | {texto: string; en?: Momento; salida?: boolean; color?: string})[]; titulo?: string; ancho?: number; tam?: number; fondo?: string; velocidad?: number}
92
92
  | {tipo: 'nombre'; texto: string; sub?: string; tam?: number; fondo?: string; colorTexto?: string; borde?: string}
93
93
  | {tipo: 'accion'; texto: string; tam?: number; color?: string; fondo?: string; colorTexto?: string}
package/lib/calidad.mjs CHANGED
@@ -149,7 +149,9 @@ export async function revisarCuadros(mp4, d) {
149
149
  // medido: escenas sin nada sobre fondo oscuro → media 13–15 y la cubeta más oscura con 97 % del cuadro;
150
150
  // tele o pared pegadas a la cámara → desviación 12–16 con media > 130; los videos buenos del canal: desviación ≥ 27
151
151
  negro: (s) => (s.media < 16 && s.desv < 9) || (s.media < 25 && s.dom > 0.9),
152
- tapado: (s) => (s.dom > 0.58 && s.luzDom > 45) || (s.desv < 18 && s.media > 60),
152
+ // (el segundo caso pide además un color dominante: una escena de día con cielo pastel también tiene desviación
153
+ // baja, ~17, pero su color más repetido no pasa del 25 %)
154
+ tapado: (s) => (s.dom > 0.58 && s.luzDom > 45) || (s.desv < 18 && s.media > 60 && s.dom > 0.4),
153
155
  plano: (s) => s.desv < 14 && s.media >= 25 && s.media <= 60,
154
156
  };
155
157
  const TEXTO = {
package/lib/config.mjs CHANGED
@@ -28,7 +28,7 @@ export const ROTULOS = ['normal', 'contorno', 'pastilla', 'sello', 'paso', 'pixe
28
28
  export const MODOS = ['rayosx', 'plano', 'holograma'];
29
29
  export const ESTILOS = ['datos-curiosos', 'explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'serie', 'infografia', '3d', 'maqueta', 'minimal'];
30
30
  export const TIPOS_CAPA = ['imagen', 'video', 'texto', 'contador', 'chip', 'etiqueta', 'numero', 'tarjeta', 'telefono', 'forma', 'icono', 'emoji', 'marca', 'particulas', 'grupo', 'modelo', 'componente', 'ambiente', 'barra', 'medidor', 'votacion', 'estado', 'noticia', 'chat', 'onda', 'globo', 'seguir', 'personaje', 'escena3d', 'ventana', 'escenario', 'recurso', 'dato', 'medida', 'trazo', 'contorno', 'fuente', 'seccion', 'insignia', 'sello', 'cursor', 'terminal', 'nombre', 'accion', 'reloj', 'cronometro', 'termometro', 'recipientes', 'ficha', 'checklist', 'mapa', 'bloqueo', 'llamada', 'recuento', 'capitulo', 'vigilancia', 'cintillo', 'fecha', 'calendario', 'dividida', 'tierra', 'linea'];
31
- export const AMBIENTES = ['cuarto-gamer', 'sala', 'arcade'];
31
+ export const AMBIENTES = ['cuarto-gamer', 'sala', 'arcade', 'taller', 'oficina'];
32
32
  export const ESCENARIOS = ['bosque', 'cuarto', 'departamento', 'tunel', 'pasillo'];
33
33
  export const FORMAS_3D = ['moneda', 'billete', 'lingote', 'cubo', 'esfera', 'corazon', 'estrella', 'rayo', 'diamante', 'trofeo', 'cohete', 'foco', 'reloj', 'planeta', 'arbol', 'nube', 'regalo', 'llave', 'candado', 'casa', 'libro', 'globo', 'medalla', 'escudo', 'pin', 'tuberia', 'muro', 'viga', 'columna', 'escalera', 'losa', 'tanque', 'suelo'];
34
34
  export const PROVEEDORES_VOZ = ['kokoro', 'elevenlabs', 'openai'];
@@ -129,7 +129,7 @@ export function revisarEstructura(v, d) {
129
129
  const capas = (s.capas ?? []).filter(Boolean);
130
130
  if (!conMundo && (s.type ?? 'capas') === 'capas' && !capas.length && !s.rotulo) {
131
131
  vacias++;
132
- add('vacia', 'aviso', `Escena ${i + 1}: solo el subtítulo sobre el fondo. Agrega una capa o dale al video un \`ambiente: 'cuarto-gamer'\` (o 'sala', 'arcade').`);
132
+ add('vacia', 'aviso', `Escena ${i + 1}: solo el subtítulo sobre el fondo. Agrega una capa o dale al video un \`ambiente: 'cuarto-gamer'\` (o 'sala', 'arcade', 'taller', 'oficina').`);
133
133
  }
134
134
  if (capas.length && capas.every((c) => SOLO_TEXTO.has(c.tipo))) soloTexto++;
135
135
  for (const c of capas) tipos.set(c.tipo, (tipos.get(c.tipo) ?? 0) + 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morfeo",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Genera videos verticales animados (Remotion + Three.js) con voz, música y subtítulos, desde un guion en código o escrito por IA.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -5,7 +5,7 @@ manera: una por línea. Cuando le corrijas algo a un video y quieras que no se r
5
5
 
6
6
  ## Lo que sí
7
7
 
8
- - Siempre un fondo vivo: un `ambiente` ('cuarto-gamer', 'sala', 'arcade') o un `mundo` propio. Nada de texto sobre un color liso.
8
+ - Siempre un fondo vivo: un `ambiente` ('cuarto-gamer', 'sala', 'arcade', 'taller', 'oficina') o un `mundo` propio. Nada de texto sobre un color liso.
9
9
  - Objetos 2D/3D (`recurso`) antes que emojis.
10
10
  - Gancho en segunda persona en los primeros 2 segundos.
11
11
 
package/python/musica.py CHANGED
@@ -323,6 +323,18 @@ def cama(nombre, N, rng):
323
323
  if nombre == "sala":
324
324
  murmullo = lp(hp(rng.standard_normal(N), 250), 1400) * 0.035 * (0.6 + 0.4 * np.abs(np.sin(2 * np.pi * 0.9 * t + np.sin(2 * np.pi * 0.23 * t) * 3)))
325
325
  return lluvia(0.03) + murmullo + zumbido
326
+ if nombre == "taller":
327
+ x = lluvia(0.025) + zumbido * 0.6
328
+ for _ in range(int(N / SR / 3)): # herramientas: clics metálicos sueltos
329
+ n = int(0.03 * SR); g = int(rng.random() * (N - n))
330
+ x[g:g + n] += 0.03 * hp(rng.standard_normal(n), 3000) * np.exp(-np.arange(n) / (0.004 * SR))
331
+ return x
332
+ if nombre == "oficina":
333
+ x = lp(hp(rng.standard_normal(N), 200), 900) * 0.02 + zumbido * 0.5 # aire acondicionado
334
+ for _ in range(int(N / SR / 1.2)): # teclas lejanas
335
+ n = int(0.012 * SR); g = int(rng.random() * (N - n))
336
+ x[g:g + n] += 0.02 * hp(rng.standard_normal(n), 2500) * np.exp(-np.arange(n) / (0.002 * SR))
337
+ return x
326
338
  if nombre == "arcade":
327
339
  x = lp(rng.standard_normal(N), 700) * 0.05 + zumbido
328
340
  for _ in range(int(N / SR * 3)): # maquinitas sonando por todos lados
@@ -342,7 +354,7 @@ def ambiente_de(v):
342
354
  return None
343
355
 
344
356
 
345
- def mix(vid, style, outdir, TIM, SPEC, durs, ev, ambiente=None):
357
+ def mix(vid, style, outdir, TIM, SPEC, durs, ev, ambiente=None, cortes=()):
346
358
  """Mezcla voz + música (con ducking) + efectos y escribe mix.wav. style=None: sin música."""
347
359
  dur = sum(durs); N = int(dur * SR); rng = np.random.default_rng(11)
348
360
  music = musica_de(vid, style, dur, N)
@@ -375,6 +387,8 @@ def mix(vid, style, outdir, TIM, SPEC, durs, ev, ambiente=None):
375
387
  if not explicito and any(abs(tc - u) < 0.25 for u in usados): continue
376
388
  usados.append(tc)
377
389
  add(sfx, (1.0 if explicito else 0.6) * efecto(ef, rng), tc)
390
+ for tc in cortes: # cortes de cámara dentro de la escena (ambientes): whoosh corto y bajito
391
+ add(sfx, 0.09 * whoosh(rng, 0.22), max(0.0, tc - 0.08))
378
392
  voice, _ = sf.read(os.path.join(outdir, "voz.wav"))
379
393
  voice = resample_poly(voice, 147, 80)
380
394
  v = np.zeros(N); v[: min(N, len(voice))] = voice[:N]
@@ -250,7 +250,22 @@ def preparar(job):
250
250
  return seg["speech"] * 0.5
251
251
 
252
252
  durs = [seg["dur"] + (0.6 if i == len(TIM) - 1 else 0) for i, seg in enumerate(TIM)]
253
- musica.mix(vid, v.get("music"), outdir, TIM, spec, durs, ev, ambiente=musica.ambiente_de(v)) # -> mix.wav (voz + música con ducking + efectos)
253
+ # cortes de cámara de los ambientes (misma regla que cortesDe() en remotion/motor/Video.tsx): un whoosh suave en cada uno
254
+ cortes = []
255
+ if musica.ambiente_de(v) or any(isinstance(c, dict) and c.get("tipo") == "ambiente" for c in (v.get("mundo") or [])):
256
+ for si, (seg, sp) in enumerate(zip(TIM, spec)):
257
+ if sp.get("cortes") is False or sp.get("type") == "cta": continue
258
+ if isinstance(sp.get("cortes"), list):
259
+ cortes += [seg["t0"] + c for c in sp["cortes"] if isinstance(c, (int, float))]; continue
260
+ if sp.get("plano") or seg["dur"] < 3.5: continue
261
+ claves = [norm(c) for c in (sp.get("claves") or []) if norm(c)]
262
+ hecho = False
263
+ for w, a0, b0 in WORDS[si]:
264
+ c = a0 - seg["t0"]
265
+ if any(norm(w).startswith(k) for k in claves) and 1.4 < c < seg["dur"] - 1.1:
266
+ cortes.append(a0); hecho = True; break
267
+ if not hecho and seg["dur"] > 7: cortes.append(seg["t0"] + seg["dur"] / 2)
268
+ musica.mix(vid, v.get("music"), outdir, TIM, spec, durs, ev, ambiente=musica.ambiente_de(v), cortes=cortes) # -> mix.wav (voz + música con ducking + efectos)
254
269
 
255
270
  escenas = []
256
271
  for si, (seg, sp) in enumerate(zip(TIM, spec)):
@@ -13,7 +13,7 @@ import {Persona3D} from './mundo3d';
13
13
  import type {Expresion, Pose} from './personas';
14
14
  import {Bloque, Lienzo3D, type Etiqueta3D, type Post3D, type Toma3D, type V3} from './tres';
15
15
 
16
- export type NombreAmbiente = 'cuarto-gamer' | 'sala' | 'arcade';
16
+ export type NombreAmbiente = 'cuarto-gamer' | 'sala' | 'arcade' | 'taller' | 'oficina';
17
17
  export type Reaccion = {en: number; expresion?: Expresion; pose?: Pose};
18
18
 
19
19
  type Def = {
@@ -448,6 +448,185 @@ const SetArcade: React.FC<{t: number}> = ({t}) => (
448
448
  </group>
449
449
  );
450
450
 
451
+
452
+ /** Tablero perforado con herramientas colgadas (desarmador, pinzas, llave, martillo). */
453
+ const Tablero: React.FC<{pos: V3}> = ({pos}) => (
454
+ <group position={pos}>
455
+ <Bloque pos={[0, 0, 0]} tam={[2.2, 1.1, 0.04]} color="#C9A57A" />
456
+ {Array.from({length: 40}, (_, i) => (
457
+ <mesh key={i} position={[-1.0 + (i % 10) * 0.22, -0.42 + Math.floor(i / 10) * 0.28, 0.025]}>
458
+ <circleGeometry args={[0.012, 6]} />
459
+ <meshBasicMaterial color="#7A5C3A" />
460
+ </mesh>
461
+ ))}
462
+ {[
463
+ [-0.8, '#E5484D', 0.5, 0.05],
464
+ [-0.5, '#3D7BFF', 0.42, 0.05],
465
+ [-0.15, '#2A2A30', 0.55, 0.08],
466
+ [0.2, '#F2B632', 0.38, 0.06],
467
+ [0.55, '#30A46C', 0.5, 0.05],
468
+ [0.85, '#8E929C', 0.45, 0.1],
469
+ ].map(([x, c, l, a], i) => (
470
+ <group key={i} position={[x as number, 0.05, 0.05]}>
471
+ <Bloque pos={[0, 0.12, 0]} tam={[a as number, (l as number) * 0.45, 0.04]} color={c as string} />
472
+ <Bloque pos={[0, -0.15, 0]} tam={[0.03, (l as number) * 0.6, 0.03]} color="#C9CCD3" metal={0.7} rugosidad={0.3} />
473
+ </group>
474
+ ))}
475
+ </group>
476
+ );
477
+
478
+ const SetTaller: React.FC<{t: number}> = ({t}) => (
479
+ <group>
480
+ <Cuarto ancho={8} fondo={6} alto={3.4} pared="#3A3F48" lateral="#33373F" piso="#4A4A4F" />
481
+ {/* mesa de trabajo con tapete verde */}
482
+ <Bloque pos={[-0.6, 0.92, -2.35]} tam={[2.8, 0.08, 1.0]} color="#8A6A48" />
483
+ {[-1.85, 0.65].map((x) => (
484
+ <Bloque key={x} pos={[x, 0.45, -2.35]} tam={[0.08, 0.9, 0.9]} color="#5A4632" />
485
+ ))}
486
+ <Bloque pos={[-0.6, 0.965, -2.3]} tam={[1.6, 0.01, 0.7]} color="#2E7D5B" />
487
+ <Tablero pos={[-0.6, 1.9, -2.88]} />
488
+ {/* control abierto: carcasa, placa verde y botones sueltos */}
489
+ <group position={[-0.7, 0.98, -2.2]} rotation={[0, 0.3, 0]}>
490
+ <Bloque pos={[-0.35, 0.03, 0]} tam={[0.45, 0.05, 0.25]} color="#2A2A30" radio={0.03} />
491
+ <Bloque pos={[0.2, 0.02, 0.02]} tam={[0.4, 0.02, 0.22]} color="#1F7A4D" />
492
+ {[0, 1, 2, 3].map((k) => (
493
+ <Bloque key={k} pos={[0.08 + (k % 2) * 0.12, 0.04, -0.04 + Math.floor(k / 2) * 0.1]} tam={[0.05, 0.02, 0.05]} color="#D4B04A" metal={0.6} />
494
+ ))}
495
+ {[0, 1, 2].map((k) => (
496
+ <mesh key={k} position={[0.55 + k * 0.08, 0.02, 0.12]}>
497
+ <cylinderGeometry args={[0.025, 0.025, 0.02, 10]} />
498
+ <meshStandardMaterial color={['#E5484D', '#3D7BFF', '#F2B632'][k]} />
499
+ </mesh>
500
+ ))}
501
+ </group>
502
+ {/* cautín con humito y lupa con brazo */}
503
+ <group position={[0.25, 0.98, -2.1]} rotation={[0, -0.6, 0.15]}>
504
+ <Bloque pos={[0, 0.03, 0]} tam={[0.4, 0.035, 0.035]} color="#E5484D" />
505
+ <Bloque pos={[0.27, 0.03, 0]} tam={[0.16, 0.015, 0.015]} color="#C9CCD3" metal={0.8} />
506
+ </group>
507
+ {Array.from({length: 6}, (_, i) => {
508
+ const f = (t * 0.45 + i / 6) % 1;
509
+ return (
510
+ <mesh key={i} position={[0.5 + Math.sin(t + i) * 0.03, 1.02 + f * 0.6, -2.1 + Math.cos(t * 0.7 + i) * 0.03]}>
511
+ <sphereGeometry args={[0.02 + f * 0.04, 6, 5]} />
512
+ <meshBasicMaterial color="#DADDE3" transparent opacity={0.4 * (1 - f)} />
513
+ </mesh>
514
+ );
515
+ })}
516
+ <group position={[-1.6, 0.96, -2.4]}>
517
+ <Bloque pos={[0, 0.03, 0]} tam={[0.2, 0.06, 0.2]} color="#2A2A30" />
518
+ <Bloque pos={[0, 0.35, 0]} tam={[0.03, 0.6, 0.03]} color="#8E929C" metal={0.6} />
519
+ <mesh position={[0.15, 0.62, 0.15]} rotation={[0.9, 0, 0]}>
520
+ <torusGeometry args={[0.14, 0.02, 8, 20]} />
521
+ <meshStandardMaterial color="#2A2A30" />
522
+ </mesh>
523
+ <mesh position={[0.15, 0.62, 0.15]} rotation={[0.9, 0, 0]}>
524
+ <circleGeometry args={[0.13, 20]} />
525
+ <meshStandardMaterial color="#BFE6FF" transparent opacity={0.35} />
526
+ </mesh>
527
+ </group>
528
+ {/* lámpara de brazo sobre la mesa */}
529
+ <group position={[0.45, 0.96, -2.6]}>
530
+ <Bloque pos={[0, 0.5, 0]} tam={[0.04, 1.0, 0.04]} color="#2A2A30" />
531
+ <mesh position={[0.12, 1.0, 0.2]} rotation={[0.6, 0, 0]}>
532
+ <coneGeometry args={[0.16, 0.22, 16, 1, true]} />
533
+ <meshStandardMaterial color="#F2B632" side={THREE.DoubleSide} emissive="#F2B632" emissiveIntensity={0.3} />
534
+ </mesh>
535
+ <spotLight position={[0.12, 0.95, 0.25]} angle={0.7} penumbra={0.6} intensity={6} distance={4} decay={1.4} color="#FFE2B0" castShadow shadow-mapSize={[1024, 1024]} shadow-bias={-0.0005} />
536
+ </group>
537
+ {/* repisa con cajas etiquetadas y una consola vieja abierta */}
538
+ <group position={[2.4, 0, -2.5]}>
539
+ {[0.5, 1.2, 1.9].map((y) => (
540
+ <Bloque key={y} pos={[0, y, 0]} tam={[1.6, 0.05, 0.6]} color="#5A4632" />
541
+ ))}
542
+ {[-1, 1].map((l) => (
543
+ <Bloque key={l} pos={[l * 0.78, 1.0, 0]} tam={[0.05, 2.0, 0.6]} color="#5A4632" />
544
+ ))}
545
+ {[0, 1, 2, 3, 4, 5].map((k) => (
546
+ <Bloque key={k} pos={[-0.5 + (k % 3) * 0.5, 0.68 + Math.floor(k / 3) * 0.7, 0]} tam={[0.42, 0.3, 0.45]} color={['#C49A6C', '#B8875A', '#D2A878'][k % 3]} />
547
+ ))}
548
+ <Bloque pos={[0, 2.05, 0]} tam={[0.6, 0.18, 0.4]} color="#C9CCD3" radio={0.03} />
549
+ </group>
550
+ <VentanaLluvia pos={[-3.88, 2.0, -0.4]} ancho={1.4} alto={1.2} t={t} semilla={7} />
551
+ <Polvo centro={[-0.2, 1.6, -2.0]} tam={[2.2, 1.6, 1.4]} t={t} />
552
+ <pointLight position={[0, 3.1, 0]} intensity={2.2} distance={9} decay={1.6} color="#FFE9C8" />
553
+ </group>
554
+ );
555
+
556
+ const SetOficina: React.FC<{t: number}> = ({t}) => (
557
+ <group>
558
+ <Cuarto ancho={9} fondo={7} alto={3.4} pared="#D8DCE3" lateral="#C9CED8" piso="#7C8696" />
559
+ {/* ventanal de atardecer con la ciudad */}
560
+ <group position={[0, 1.9, -3.38]}>
561
+ <mesh>
562
+ <planeGeometry args={[6.4, 2.0]} />
563
+ <meshStandardMaterial color="#F4A26B" emissive="#F4A26B" emissiveIntensity={0.55} />
564
+ </mesh>
565
+ {Array.from({length: 12}, (_, i) => {
566
+ const h = 0.4 + azarN(i * 2.7) * 1.2;
567
+ return <Bloque key={i} pos={[-3.0 + i * 0.55, -1.0 + h / 2, 0.02]} tam={[0.45, h, 0.01]} color="#5A4A6A" />;
568
+ })}
569
+ {[-2.1, 0, 2.1].map((x) => (
570
+ <Bloque key={x} pos={[x + 1.05, 0, 0.04]} tam={[0.06, 2.0, 0.04]} color="#2A2A30" />
571
+ ))}
572
+ </group>
573
+ {/* dos escritorios con monitor; en el de enfrente trabaja la persona */}
574
+ {[
575
+ [-1.6, -1.6],
576
+ [1.7, -1.6],
577
+ ].map(([x, z], i) => (
578
+ <group key={i} position={[x, 0, z]}>
579
+ <Bloque pos={[0, 0.74, 0]} tam={[1.8, 0.06, 0.8]} color="#EDEDED" />
580
+ {[-1, 1].map((l) => (
581
+ <Bloque key={l} pos={[l * 0.85, 0.37, 0]} tam={[0.05, 0.74, 0.75]} color="#8E929C" metal={0.5} />
582
+ ))}
583
+ <Bloque pos={[0, 0.84, -0.25]} tam={[0.25, 0.14, 0.15]} color="#2A2A30" />
584
+ <Tele3D pos={[0, 1.15, -0.28]} ancho={0.9} t={t} fase={i * 2.1} luz={1.2} />
585
+ <Bloque pos={[0.1, 0.78, 0.15]} tam={[0.5, 0.02, 0.16]} color="#2A2A30" />
586
+ <mesh position={[0.6, 0.82, 0.05]}>
587
+ <cylinderGeometry args={[0.05, 0.045, 0.1, 12]} />
588
+ <meshStandardMaterial color={i ? '#E5484D' : '#3D7BFF'} />
589
+ </mesh>
590
+ <SillaGamer pos={[0, 0, 0.75]} color="#2F6FE0" />
591
+ </group>
592
+ ))}
593
+ {/* pizarrón con gráfica, garrafón de agua y plantas */}
594
+ <group position={[-4.38, 1.7, 0.4]} rotation={[0, Math.PI / 2, 0]}>
595
+ <Bloque pos={[0, 0, 0]} tam={[2.0, 1.1, 0.04]} color="#FFFFFF" />
596
+ {[0, 1, 2, 3].map((k) => (
597
+ <Bloque key={k} pos={[-0.6 + k * 0.4, -0.35 + (0.15 + k * 0.12) / 2, 0.03]} tam={[0.22, 0.15 + k * 0.12, 0.01]} color={['#3D7BFF', '#30A46C', '#F2B632', '#E5484D'][k]} />
598
+ ))}
599
+ </group>
600
+ <group position={[3.9, 0, 0.6]}>
601
+ <Bloque pos={[0, 0.5, 0]} tam={[0.4, 1.0, 0.4]} color="#EDEDED" />
602
+ <mesh position={[0, 1.25, 0]}>
603
+ <cylinderGeometry args={[0.17, 0.17, 0.5, 14]} />
604
+ <meshStandardMaterial color="#7FC4FF" transparent opacity={0.7} />
605
+ </mesh>
606
+ </group>
607
+ {[-3.9, 3.9].map((x) => (
608
+ <group key={x} position={[x, 0, -2.9]}>
609
+ <Bloque pos={[0, 0.25, 0]} tam={[0.45, 0.5, 0.45]} color="#C47A4A" />
610
+ {[0, 1, 2, 3, 4].map((k) => (
611
+ <mesh key={k} position={[Math.cos(k * 1.3) * 0.12, 0.8 + k * 0.1, Math.sin(k * 1.3) * 0.12]} rotation={[0.3 * Math.cos(k), k, 0.5 + Math.sin(t * 0.8 + k) * 0.04]}>
612
+ <coneGeometry args={[0.16, 0.7, 4]} />
613
+ <meshStandardMaterial color="#3E9B5A" flatShading />
614
+ </mesh>
615
+ ))}
616
+ </group>
617
+ ))}
618
+ {/* lámparas de techo */}
619
+ {[-2, 2].map((x) => (
620
+ <group key={x} position={[x, 3.32, -0.6]}>
621
+ <Emisivo pos={[0, 0, 0]} tam={[1.4, 0.04, 0.3]} color="#FFFFFF" fuerza={0.9} />
622
+ <pointLight position={[0, -0.3, 0]} intensity={3} distance={8} decay={1.5} color="#FFF4E0" castShadow={x < 0} shadow-mapSize={[1024, 1024]} shadow-bias={-0.0005} />
623
+ </group>
624
+ ))}
625
+ <directionalLight position={[0, 2.5, -6]} intensity={0.8} color="#FFB27A" />
626
+ <Polvo centro={[0, 1.8, -1.6]} tam={[5, 2.2, 2]} t={t} />
627
+ </group>
628
+ );
629
+
451
630
  const AMBIENTES: Record<NombreAmbiente, Def> = {
452
631
  'cuarto-gamer': {
453
632
  luz: 'noche',
@@ -456,17 +635,17 @@ const AMBIENTES: Record<NombreAmbiente, Def> = {
456
635
  post: 'cine',
457
636
  planos: {
458
637
  general: {pos: [2.6, 2.6, 3.4], mirar: [-0.7, 1.15, -1.6], fov: 50, a: {pos: [2.1, 2.4, 2.8]}},
459
- persona: {pos: [0.7, 1.55, -2.15], mirar: [-1.0, 1.2, -0.95], fov: 44, a: {pos: [0.55, 1.5, -2.05]}},
638
+ persona: {pos: [1.1, 1.6, -2.35], mirar: [-1.0, 1.15, -0.95], fov: 46, a: {pos: [0.95, 1.56, -2.25]}},
460
639
  escritorio: {pos: [0.2, 1.8, 0.5], mirar: [-1.0, 1.25, -2.5], fov: 42, a: {pos: [0.05, 1.7, 0.2]}},
461
- tele: {pos: [0.5, 1.75, 0.3], mirar: [-1.0, 1.25, -2.72], fov: 42, a: {pos: [0.35, 1.7, 0.0]}},
640
+ tele: {pos: [-2.1, 1.9, 1.7], mirar: [-1.0, 1.3, -2.72], fov: 44, a: {pos: [-2.0, 1.85, 1.3]}},
462
641
  consola: {pos: [1.0, 1.9, -0.5], mirar: [-0.5, 0.95, -2.35], fov: 42, a: {pos: [0.85, 1.82, -0.75]}},
463
642
  estante: {pos: [-1.9, 1.75, 0.8], mirar: [-3.8, 1.55, -1.2], fov: 42, a: {pos: [-2.1, 1.7, 0.5]}},
464
643
  ventana: {pos: [0.9, 1.75, 0.8], mirar: [2.3, 1.9, -2.88], fov: 44, a: {pos: [1.05, 1.75, 0.5]}},
465
644
  contrapicado: {pos: [0.9, 0.45, -2.2], mirar: [-1.0, 1.35, -0.95], fov: 50},
466
645
  cenital: {pos: [-0.9, 3.4, 0.4], mirar: [-1.0, 0.7, -1.5], fov: 52, a: {pos: [-0.9, 3.3, 0.1]}},
467
646
  },
468
- orden: ['general', 'persona', 'escritorio', 'tele', 'contrapicado', 'consola', 'estante', 'ventana', 'cenital'],
469
- ordenFondo: ['general', 'escritorio', 'estante', 'ventana', 'cenital', 'consola'],
647
+ orden: ['general', 'persona', 'escritorio', 'tele', 'consola', 'estante', 'ventana'],
648
+ ordenFondo: ['general', 'escritorio', 'estante', 'ventana', 'consola'],
470
649
  anclas: {tele: [-1.0, 1.42, -2.72], consola: [-0.45, 0.92, -2.35], control: [0.1, 0.86, -2.1], escritorio: [-1.0, 0.8, -2.4], persona: [-1.0, 1.3, -0.95], estante: [-3.8, 1.5, -1.2], ventana: [2.3, 2.1, -2.88], cama: [2.75, 0.6, -1.6], silla: [-1.0, 0.9, -0.95]},
471
650
  persona: {pos: [-1.0, 0, -0.98], rot: [0, 180, 0], pose: 'sentado'},
472
651
  Set: SetCuartoGamer,
@@ -481,7 +660,7 @@ const AMBIENTES: Record<NombreAmbiente, Def> = {
481
660
  persona: {pos: [0.5, 1.3, -1.6], mirar: [0, 1.15, 0.9], fov: 44, a: {pos: [0.4, 1.28, -1.4]}},
482
661
  tele: {pos: [0.7, 1.6, -0.3], mirar: [0, 1.15, -3.2], fov: 42, a: {pos: [0.6, 1.55, -0.6]}},
483
662
  sillon: {pos: [2.6, 1.6, -0.6], mirar: [0, 0.8, 0.9], fov: 42, a: {pos: [2.3, 1.5, -0.4]}},
484
- gato: {pos: [2.4, 1.45, -0.5], mirar: [1.2, 0.9, 0.95], fov: 40, a: {pos: [2.25, 1.4, -0.35]}},
663
+ gato: {pos: [2.7, 2.0, -0.9], mirar: [1.1, 0.85, 0.9], fov: 44, a: {pos: [2.55, 1.95, -0.75]}},
485
664
  espalda: {pos: [0.6, 1.9, 3.0], mirar: [0, 1.1, -2.6], fov: 44, a: {pos: [0.5, 1.8, 2.6]}},
486
665
  lampara: {pos: [-1.6, 1.6, 0.4], mirar: [-3.4, 1.4, -2.4], fov: 44},
487
666
  ventana: {pos: [-1.8, 1.7, 1.6], mirar: [-4.38, 1.9, -0.6], fov: 44, a: {pos: [-2.0, 1.7, 1.3]}},
@@ -500,18 +679,58 @@ const AMBIENTES: Record<NombreAmbiente, Def> = {
500
679
  planos: {
501
680
  general: {pos: [3.2, 2.6, 3.6], mirar: [-0.4, 1.3, -2.2], fov: 50, a: {pos: [2.6, 2.4, 3.0]}},
502
681
  persona: {pos: [1.7, 1.6, -2.7], mirar: [0.6, 1.4, -0.95], fov: 44, a: {pos: [1.6, 1.58, -2.55]}},
503
- maquina: {pos: [1.3, 1.75, -1.2], mirar: [0.6, 1.35, -3.4], fov: 42, a: {pos: [1.2, 1.7, -1.45]}},
682
+ maquina: {pos: [1.7, 1.95, -0.5], mirar: [0.4, 1.3, -3.3], fov: 44, a: {pos: [1.6, 1.9, -0.75]}},
504
683
  pasillo: {pos: [-3.6, 1.6, 2.6], mirar: [-4.0, 1.2, -2.2], fov: 46, a: {pos: [-3.5, 1.6, 2.1]}},
505
684
  neon: {pos: [1.4, 2.4, -1.2], mirar: [2.6, 2.9, -3.88], fov: 42},
506
685
  contrapicado: {pos: [1.8, 0.5, 0.4], mirar: [0.4, 1.3, -3.0], fov: 52},
507
686
  espalda: {pos: [2.3, 1.9, 1.3], mirar: [-0.2, 1.35, -3.4], fov: 46, a: {pos: [2.15, 1.85, 1.0]}},
508
687
  },
509
- orden: ['general', 'persona', 'maquina', 'espalda', 'pasillo', 'contrapicado'],
510
- ordenFondo: ['general', 'espalda', 'pasillo', 'maquina'],
688
+ orden: ['general', 'persona', 'maquina', 'espalda', 'pasillo'],
689
+ // general y espalda dejan a la persona al centro, justo donde caen las capas 2D
690
+ ordenFondo: ['pasillo', 'maquina'],
511
691
  anclas: {maquina: [0.6, 1.42, -3.0], persona: [0.6, 1.6, -0.9], neon: [2.6, 2.9, -3.88], pasillo: [-4.4, 1.4, -0.6]},
512
692
  persona: {pos: [0.6, 0, -0.95], rot: [0, 180, 0], pose: 'parado'},
513
693
  Set: SetArcade,
514
694
  },
695
+ taller: {
696
+ luz: 'noche',
697
+ fondo: '#0E0F14',
698
+ niebla: [10, 24],
699
+ post: 'cine',
700
+ planos: {
701
+ general: {pos: [2.4, 2.4, 3.2], mirar: [-0.4, 1.2, -2.0], fov: 50, a: {pos: [2.0, 2.2, 2.6]}},
702
+ mesa: {pos: [0.3, 1.9, -0.6], mirar: [-0.6, 1.0, -2.3], fov: 42, a: {pos: [0.2, 1.8, -0.85]}},
703
+ control: {pos: [0.2, 2.1, -0.6], mirar: [-0.7, 1.0, -2.2], fov: 44, a: {pos: [0.1, 2.0, -0.8]}},
704
+ persona: {pos: [1.4, 1.7, -2.1], mirar: [-1.2, 1.3, -1.4], fov: 46, a: {pos: [1.25, 1.66, -2.0]}},
705
+ tablero: {pos: [0.6, 1.9, 0.4], mirar: [-0.6, 1.8, -2.88], fov: 44, a: {pos: [0.5, 1.88, 0.1]}},
706
+ repisa: {pos: [0.5, 1.6, 0.6], mirar: [2.4, 1.3, -2.5], fov: 44, a: {pos: [0.6, 1.6, 0.3]}},
707
+ cenital: {pos: [-0.6, 3.2, -0.9], mirar: [-0.6, 0.95, -2.2], fov: 50},
708
+ },
709
+ orden: ['general', 'persona', 'mesa', 'control', 'tablero', 'repisa', 'cenital'],
710
+ ordenFondo: ['general', 'mesa', 'tablero', 'repisa', 'cenital'],
711
+ anclas: {mesa: [-0.6, 1.0, -2.35], control: [-0.7, 1.02, -2.2], cautin: [0.25, 1.02, -2.1], lupa: [-1.45, 1.6, -2.25], tablero: [-0.6, 1.9, -2.88], repisa: [2.4, 1.3, -2.5], persona: [-1.2, 1.65, -1.4]},
712
+ persona: {pos: [-1.2, 0, -1.45], rot: [0, 160, 0], pose: 'parado'},
713
+ Set: SetTaller,
714
+ },
715
+ oficina: {
716
+ luz: 'noche',
717
+ fondo: '#1A1620',
718
+ niebla: [12, 30],
719
+ post: 'cine',
720
+ planos: {
721
+ general: {pos: [3.0, 2.6, 3.8], mirar: [-0.3, 1.1, -1.6], fov: 50, a: {pos: [2.5, 2.4, 3.2]}},
722
+ persona: {pos: [0.0, 1.6, -2.2], mirar: [-1.6, 1.22, -0.85], fov: 44, a: {pos: [-0.1, 1.58, -2.05]}},
723
+ monitor: {pos: [-0.2, 2.1, 0.3], mirar: [-1.6, 1.1, -1.88], fov: 42, a: {pos: [-0.3, 2.0, 0.05]}},
724
+ ventana: {pos: [0.6, 1.6, 1.6], mirar: [0, 1.9, -3.38], fov: 46, a: {pos: [0.5, 1.6, 1.2]}},
725
+ pizarron: {pos: [-1.2, 1.7, 1.6], mirar: [-4.38, 1.6, 0.4], fov: 44},
726
+ companero: {pos: [2.9, 1.7, 0.6], mirar: [1.7, 1.0, -1.7], fov: 44, a: {pos: [2.8, 1.65, 0.35]}},
727
+ },
728
+ orden: ['general', 'persona', 'monitor', 'ventana', 'pizarron', 'companero'],
729
+ ordenFondo: ['general', 'monitor', 'ventana', 'pizarron', 'companero'],
730
+ anclas: {monitor: [-1.6, 1.15, -1.88], escritorio: [-1.6, 0.8, -1.6], ventana: [0, 1.9, -3.38], pizarron: [-4.38, 1.7, 0.4], garrafon: [3.9, 1.25, 0.6], persona: [-1.6, 1.3, -0.85]},
731
+ persona: {pos: [-1.6, 0, -0.88], rot: [0, 180, 0], pose: 'sentado'},
732
+ Set: SetOficina,
733
+ },
515
734
  };
516
735
 
517
736
  export const NOMBRES_AMBIENTES = Object.keys(AMBIENTES) as NombreAmbiente[];
@@ -587,14 +806,19 @@ export type PropsAmbiente = {
587
806
  function tramos(nombre: NombreAmbiente, base: Toma3D[], inicios: number[], cortes: number[][], conCapas: boolean[]) {
588
807
  const def = AMBIENTES[nombre];
589
808
  const out: {ini: number; toma: Toma3D; escena: number}[] = [];
590
- base.forEach((toma, i) => {
809
+ const igual = (a?: Toma3D, b?: Toma3D) => !!a && !!b && a.pos === b.pos && a.mirar === b.mirar;
810
+ base.forEach((toma0, i) => {
591
811
  const ini = inicios[i] ?? (out.length ? out[out.length - 1].ini + 3 : 0);
592
- out.push({ini, toma, escena: i + 1});
593
812
  const lista = (conCapas[i] ? def.ordenFondo : def.orden).map((n) => def.planos[n]);
594
- let j = Math.max(0, lista.indexOf(toma));
813
+ // si el último tramo (un corte de la escena anterior) ya usó este plano, la escena toma el siguiente
814
+ let toma = toma0;
815
+ if (igual(toma, out[out.length - 1]?.toma)) toma = lista[(Math.max(0, lista.findIndex((x) => igual(x, toma))) + 1) % lista.length];
816
+ // entre escenas, corte seco: el viaje de cámara cruzaba paredes y muebles del set
817
+ out.push({ini, toma: i > 0 ? {...toma, corte: true} : toma, escena: i + 1});
818
+ let j = Math.max(0, lista.findIndex((x) => igual(x, toma)));
595
819
  for (const c of cortes[i] ?? []) {
596
820
  j = (j + 2) % lista.length;
597
- if (lista[j] === toma || lista[j] === base[i + 1]) j = (j + 1) % lista.length;
821
+ if (igual(lista[j], toma) || igual(lista[j], base[i + 1])) j = (j + 1) % lista.length;
598
822
  out.push({ini: ini + c, toma: {...lista[j], corte: true}, escena: i + 1});
599
823
  }
600
824
  });
@@ -413,7 +413,8 @@ const MarcaEsquina: React.FC<{tema: Tema}> = ({tema}) => {
413
413
  };
414
414
 
415
415
  export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string, any>; miniatura?: boolean; sinMarca?: boolean}> = ({d, T, estilos, miniatura, sinMarca}) => {
416
- const tema = temaDe(d.estilo, d.tema, estilos);
416
+ const conAmbiente = !!d.mundo?.some((c: any) => c.tipo === 'ambiente');
417
+ const tema = temaDe(d.estilo, d.tema, estilos, conAmbiente);
417
418
  const n = d.escenas.length;
418
419
  let si = d.escenas.findIndex((e) => T < e.t0 + e.dur);
419
420
  if (si < 0) si = n - 1;
@@ -473,6 +474,8 @@ export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string,
473
474
  <Mundo d={d} si={si} t={t} T={T} tema={tema} />
474
475
  </AbsoluteFill>
475
476
  ) : null}
477
+ {/* un ambiente puede ser claro (oficina): sombra arriba, donde van subtítulos y titular, para que el texto blanco se lea (en el cierre, todo el cuadro) */}
478
+ {conAmbiente ? <AbsoluteFill style={{background: e.tipo === 'cta' ? 'rgba(0,0,0,0.42)' : 'linear-gradient(180deg, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.38) 30%, rgba(0,0,0,0) 46%)'}} /> : null}
476
479
  {enTransicion && prev ? (
477
480
  <AbsoluteFill style={estiloPrevio}>
478
481
  <EscenaVista e={prev} t={prev.dur + t} tema={tema} semilla={semilla} sinFondo={conMundo} />
@@ -204,6 +204,7 @@ export const Anotacion: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
204
204
  border: `${Math.max(4, tam * 0.1)}px solid ${color}`,
205
205
  outline: `${Math.max(2, tam * 0.04)}px solid ${color}`,
206
206
  outlineOffset: tam * 0.1,
207
+ background: c.fondo,
207
208
  borderRadius: tam * 0.2,
208
209
  padding: `${tam * 0.12}px ${tam * 0.42}px`,
209
210
  fontFamily: T.fuentes.titulo,
@@ -11,6 +11,22 @@
11
11
  import type {Marca} from '../tipos';
12
12
  import type {Capa} from './capas';
13
13
  import type {Tema} from './tema';
14
+ import {ancho} from '../estilo';
15
+
16
+ /** Renglones que ocupa un texto al partirse por palabras en `maxAncho` (como lo hace el navegador). */
17
+ const renglonesDe = (texto: string, fuente: string, tam: number, maxAncho: number, mayus?: boolean) => {
18
+ const palabras = (mayus ? texto.toUpperCase() : texto).split(/\s+/).filter(Boolean);
19
+ let n = 1;
20
+ let linea = '';
21
+ for (const w of palabras) {
22
+ const prueba = linea ? `${linea} ${w}` : w;
23
+ if (linea && ancho(prueba, fuente, tam, 800) > maxAncho) {
24
+ n++;
25
+ linea = w;
26
+ } else linea = prueba;
27
+ }
28
+ return n;
29
+ };
14
30
 
15
31
  type P = Record<string, any>;
16
32
 
@@ -22,10 +38,16 @@ export function capasDe(tipo: string, p: P, tema: Tema, marca: Marca): Capa[] {
22
38
  case 'titular': {
23
39
  const y = p.y ?? 560;
24
40
  const out: Capa[] = [];
25
- if (p.etiqueta) out.push({tipo: 'etiqueta', texto: p.etiqueta, x: 80, y: y - 92, ancla: 'izquierda', entrada: 'aparecer'});
26
- out.push({tipo: 'texto', texto: p.texto ?? '', x: 80, y, ancla: 'izquierda', alinear: 'left', tam: p.tam ?? 104, maxAncho: p.numero != null ? 800 : 920, entrada: 'subir', en: p.en});
41
+ const tam = p.tam ?? 104;
42
+ const maxAncho = p.numero != null ? 800 : 920;
43
+ // el texto va centrado en `y`: si se parte en 2+ renglones crece hacia arriba y abajo, así que la etiqueta
44
+ // y el subtítulo se separan medio renglón por cada renglón extra (ajuste por palabras con la fuente real)
45
+ const renglones = renglonesDe(String(p.texto ?? '').replace(/\*/g, ''), tema.fuentes.titulo, tam, maxAncho, tema.tituloMayusculas);
46
+ const extraY = (renglones - 1) * tam * 0.525;
47
+ if (p.etiqueta) out.push({tipo: 'etiqueta', texto: p.etiqueta, x: 80, y: y - 92 - extraY, ancla: 'izquierda', entrada: 'aparecer'});
48
+ out.push({tipo: 'texto', texto: p.texto ?? '', x: 80, y, ancla: 'izquierda', alinear: 'left', tam, maxAncho, entrada: 'subir', en: p.en});
27
49
  if (p.numero != null) out.push({tipo: 'insignia', texto: String(p.numero), x: 1000, y, ancla: 'derecha', entrada: 'rebote', en: 0.15});
28
- if (p.sub) out.push({tipo: 'texto', texto: p.sub, fuente: 'texto', tam: 44, peso: 600, color: tema.colores.suave, x: 80, y: y + 110, ancla: 'izquierda', alinear: 'left', entrada: 'aparecer', en: 0.3});
50
+ if (p.sub) out.push({tipo: 'texto', texto: p.sub, fuente: 'texto', tam: 44, peso: 600, color: tema.colores.suave, x: 80, y: y + 110 + extraY, ancla: 'izquierda', alinear: 'left', entrada: 'aparecer', en: 0.3});
29
51
  return [...out, ...extra];
30
52
  }
31
53
  case 'dato': {
@@ -252,13 +252,27 @@ export const mezclar = <T,>(a: T, b: any): T => {
252
252
  return out;
253
253
  };
254
254
 
255
- /** Tema final: estilo base (o uno propio del config, que puede heredar con `base`) + ajustes del video. */
256
- export function temaDe(estilo: string | undefined, ajustes: Record<string, any> | undefined, propios: Record<string, any> = {}): Tema {
255
+ /**
256
+ * Estilos de papel o cielo (texto oscuro) sobre un ambiente 3D de noche: el texto pasa a claro para que se lea.
257
+ * Los ajustes `tema` del video se aplican después, así que siguen mandando.
258
+ */
259
+ const SOBRE_NOCHE = {
260
+ textura: 'ninguna',
261
+ colores: {texto: '#FFFFFF', suave: 'rgba(255,255,255,0.6)', tarjeta: '#1C1D24', tarjetaTexto: '#FFFFFF', borde: '#FFFFFF'},
262
+ subtitulos: {color: '#FFFFFF', pendiente: 'rgba(255,255,255,0.5)'},
263
+ };
264
+
265
+ /**
266
+ * Tema final: estilo base (o uno propio del config, que puede heredar con `base`) + ajustes del video.
267
+ * `oscuro`: el video corre sobre un ambiente de noche (capa `ambiente` en el mundo).
268
+ */
269
+ export function temaDe(estilo: string | undefined, ajustes: Record<string, any> | undefined, propios: Record<string, any> = {}, oscuro = false): Tema {
257
270
  const resolver = (nombre: string, n = 0): Tema => {
258
271
  if (ESTILOS[nombre]) return ESTILOS[nombre];
259
272
  const p = propios[nombre];
260
273
  if (p && n < 5) return mezclar(resolver(p.base ?? 'minimal', n + 1), p);
261
274
  return ESTILOS.minimal;
262
275
  };
263
- return mezclar(resolver(estilo ?? 'minimal'), ajustes ?? {});
276
+ const base = resolver(estilo ?? 'minimal');
277
+ return mezclar(oscuro && base.colores.texto !== '#FFFFFF' ? mezclar(base, SOBRE_NOCHE) : base, ajustes ?? {});
264
278
  }