morfeo 0.0.0-stage → 0.1.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.
Files changed (51) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +210 -2
  3. package/assets/fuentes/Anton.ttf +0 -0
  4. package/assets/fuentes/JetBrainsMono.ttf +0 -0
  5. package/assets/fuentes/Montserrat.ttf +0 -0
  6. package/bin/morfeo.mjs +184 -0
  7. package/bin/postinstall.mjs +10 -0
  8. package/index.d.ts +246 -0
  9. package/lib/config.mjs +241 -0
  10. package/lib/entorno.mjs +93 -0
  11. package/lib/guion.mjs +309 -0
  12. package/lib/imagenes.mjs +99 -0
  13. package/lib/index.mjs +15 -0
  14. package/lib/instalar.mjs +193 -0
  15. package/lib/navegador.mjs +64 -0
  16. package/lib/personajes.mjs +94 -0
  17. package/lib/render.mjs +278 -0
  18. package/lib/voces.mjs +122 -0
  19. package/lib/whisper.mjs +45 -0
  20. package/package.json +84 -5
  21. package/plantilla/assets/.gitkeep +0 -0
  22. package/plantilla/env.example +7 -0
  23. package/plantilla/escenas/Barras.tsx +15 -0
  24. package/plantilla/gitignore +4 -0
  25. package/plantilla/guiones/cachalote.mjs +24 -0
  26. package/plantilla/guiones/tutorial.mjs +16 -0
  27. package/plantilla/morfeo.config.mjs +43 -0
  28. package/plantilla/package.json +13 -0
  29. package/plantilla/videos.mjs +25 -0
  30. package/python/musica.py +221 -0
  31. package/python/preparar.py +268 -0
  32. package/python/requirements.txt +5 -0
  33. package/remotion/Root.tsx +35 -0
  34. package/remotion/Short.tsx +278 -0
  35. package/remotion/escenas.tsx +430 -0
  36. package/remotion/estilo.ts +73 -0
  37. package/remotion/iconos.ts +326 -0
  38. package/remotion/index.ts +4 -0
  39. package/remotion/motor/Video.tsx +119 -0
  40. package/remotion/motor/capas.tsx +547 -0
  41. package/remotion/motor/fondo.tsx +149 -0
  42. package/remotion/motor/modelo3d.tsx +252 -0
  43. package/remotion/motor/plantillas.tsx +123 -0
  44. package/remotion/motor/registro.ts +11 -0
  45. package/remotion/motor/subtitulos.tsx +137 -0
  46. package/remotion/motor/tema.ts +202 -0
  47. package/remotion/motor/util.ts +66 -0
  48. package/remotion/motor/widgets.tsx +248 -0
  49. package/remotion/piezas.tsx +181 -0
  50. package/remotion/tipos.ts +43 -0
  51. package/remotion/tres/Capa3D.tsx +153 -0
@@ -0,0 +1,181 @@
1
+ // Piezas reutilizables: texto con contorno, pastillas, íconos pixel, resplandores y avatar.
2
+ import React, {createContext, useContext} from 'react';
3
+ import {Img, staticFile} from 'remotion';
4
+ import {ANTON, MONO, MONT, NEGRO, ancho} from './estilo';
5
+ import {ICONOS} from './iconos';
6
+ import {MARCA_VACIA, type Marca} from './tipos';
7
+
8
+ export const MarcaContexto = createContext<Marca>(MARCA_VACIA);
9
+ export const useMarca = () => useContext(MarcaContexto);
10
+
11
+ type Fuente = 'anton' | 'mont' | 'mono';
12
+ const FAMILIA: Record<Fuente, string> = {anton: ANTON, mont: MONT, mono: MONO};
13
+
14
+ /** Texto centrado en (x, y) con contorno negro y sombra, como los títulos del canal. */
15
+ export const Texto: React.FC<{
16
+ x: number;
17
+ y: number;
18
+ s: string;
19
+ size: number;
20
+ color?: string;
21
+ stroke?: number;
22
+ escala?: number;
23
+ rot?: number; // grados, positivo = contra reloj (como el motor anterior)
24
+ fuente?: Fuente;
25
+ peso?: number;
26
+ sombra?: boolean;
27
+ ancla?: 'centro' | 'izquierda';
28
+ opacidad?: number;
29
+ }> = ({x, y, s, size, color = '#fff', stroke = 0, escala = 1, rot = 0, fuente = 'anton', peso, sombra = true, ancla = 'centro', opacidad = 1}) => {
30
+ if (escala <= 0.01) return null;
31
+ const tx = ancla === 'centro' ? '-50%' : '0';
32
+ return (
33
+ <div
34
+ style={{
35
+ position: 'absolute',
36
+ left: x,
37
+ top: y,
38
+ transform: `translate(${tx}, -50%) scale(${escala}) rotate(${-rot}deg)`,
39
+ transformOrigin: ancla === 'centro' ? 'center' : 'left center',
40
+ fontFamily: FAMILIA[fuente],
41
+ fontWeight: peso ?? (fuente === 'mont' ? 800 : fuente === 'mono' ? 700 : 400),
42
+ fontSize: size,
43
+ lineHeight: 1,
44
+ whiteSpace: 'pre',
45
+ color,
46
+ opacity: opacidad,
47
+ WebkitTextStroke: stroke ? `${stroke * 2}px ${NEGRO}` : undefined,
48
+ paintOrder: 'stroke fill',
49
+ filter: sombra ? 'drop-shadow(8px 10px 0 rgba(0,0,0,0.55))' : undefined,
50
+ }}
51
+ >
52
+ {s}
53
+ </div>
54
+ );
55
+ };
56
+
57
+ /** Pastilla redonda con texto (etiquetas como "DATO CURIOSO"). */
58
+ export const Pastilla: React.FC<{cx: number; cy: number; s: string; size?: number; fondo: string; color?: string; padx?: number; pady?: number}> = ({
59
+ cx,
60
+ cy,
61
+ s,
62
+ size = 40,
63
+ fondo,
64
+ color = '#fff',
65
+ padx = 34,
66
+ pady = 16,
67
+ }) => {
68
+ const w = ancho(s, MONT, size, 900) + padx * 2;
69
+ const h = size + pady * 2;
70
+ return (
71
+ <div
72
+ style={{
73
+ position: 'absolute',
74
+ left: cx - w / 2,
75
+ top: cy - h / 2,
76
+ width: w,
77
+ height: h,
78
+ borderRadius: h,
79
+ background: fondo,
80
+ color,
81
+ fontFamily: MONT,
82
+ fontWeight: 900,
83
+ fontSize: size,
84
+ display: 'flex',
85
+ alignItems: 'center',
86
+ justifyContent: 'center',
87
+ whiteSpace: 'pre',
88
+ }}
89
+ >
90
+ {s}
91
+ </div>
92
+ );
93
+ };
94
+
95
+ /** Tamaño de cuadro para que un ícono quepa en una caja de `caja` px. */
96
+ export const tamIcono = (nombre: string, caja: number) => {
97
+ const g = (ICONOS[nombre] ?? ICONOS.question).grid;
98
+ return caja / Math.max(g[0].length, g.length * 1.15);
99
+ };
100
+
101
+ /** Ícono pixel art centrado en (cx, cy); `u` = tamaño de cada cuadro. */
102
+ export const Icono: React.FC<{nombre: string; cx: number; cy: number; u: number; color?: string; sombra?: boolean}> = ({nombre, cx, cy, u, color, sombra = true}) => {
103
+ if (u <= 0.05) return null;
104
+ const ic = ICONOS[nombre] ?? ICONOS.question;
105
+ const rows = ic.grid.length;
106
+ const cols = ic.grid[0].length;
107
+ const celdas: [number, number][] = [];
108
+ ic.grid.forEach((row, r) => [...row].forEach((ch, c) => ch === '1' && celdas.push([c, r])));
109
+ const w = cols * u + u * 0.3;
110
+ const h = rows * u + u * 0.3;
111
+ return (
112
+ <svg
113
+ style={{position: 'absolute', left: cx - (cols / 2) * u, top: cy - (rows / 2) * u, overflow: 'visible'}}
114
+ width={w}
115
+ height={h}
116
+ shapeRendering="crispEdges"
117
+ >
118
+ {sombra && celdas.map(([c, r]) => <rect key={`s${c}-${r}`} x={c * u + u * 0.22} y={r * u + u * 0.22} width={u + 0.5} height={u + 0.5} fill={NEGRO} />)}
119
+ {celdas.map(([c, r]) => (
120
+ <rect key={`${c}-${r}`} x={c * u} y={r * u} width={u + 0.5} height={u + 0.5} fill={color ?? ic.color} />
121
+ ))}
122
+ </svg>
123
+ );
124
+ };
125
+
126
+ /** Resplandor suave (elipse difuminada). */
127
+ export const Resplandor: React.FC<{cx: number; cy: number; rx: number; ry: number; color: string; fuerza: number}> = ({cx, cy, rx, ry, color, fuerza}) =>
128
+ fuerza <= 0 ? null : (
129
+ <div
130
+ style={{
131
+ position: 'absolute',
132
+ left: cx - rx * 1.6,
133
+ top: cy - ry * 1.6,
134
+ width: rx * 3.2,
135
+ height: ry * 3.2,
136
+ background: `radial-gradient(ellipse closest-side, ${color} 0%, ${color} 35%, transparent 100%)`,
137
+ opacity: fuerza,
138
+ }}
139
+ />
140
+ );
141
+
142
+ /** Avatar redondo con aro blanco: el logo de la marca, o su inicial si no hay logo. */
143
+ export const Avatar: React.FC<{x: number; y: number; size: number; aro: number}> = ({x, y, size, aro}) => {
144
+ const marca = useMarca();
145
+ return (
146
+ <div
147
+ style={{
148
+ position: 'absolute',
149
+ left: x,
150
+ top: y,
151
+ width: size + aro * 2,
152
+ height: size + aro * 2,
153
+ borderRadius: '50%',
154
+ background: '#fff',
155
+ padding: aro,
156
+ boxSizing: 'border-box',
157
+ }}
158
+ >
159
+ {marca.logo ? (
160
+ <Img src={staticFile(marca.logo)} style={{width: size, height: size, borderRadius: '50%', display: 'block', objectFit: 'cover'}} />
161
+ ) : (
162
+ <div
163
+ style={{
164
+ width: size,
165
+ height: size,
166
+ borderRadius: '50%',
167
+ background: 'linear-gradient(#5A3CAA, #2A1650)',
168
+ color: '#fff',
169
+ fontFamily: ANTON,
170
+ fontSize: size * 0.55,
171
+ display: 'flex',
172
+ alignItems: 'center',
173
+ justifyContent: 'center',
174
+ }}
175
+ >
176
+ {(marca.nombre.trim()[0] || 'M').toUpperCase()}
177
+ </div>
178
+ )}
179
+ </div>
180
+ );
181
+ };
@@ -0,0 +1,43 @@
1
+ // Formato de <publico>/v/<id>/datos.json (lo escribe python/preparar.py)
2
+ export type Fila = [string, string, number]; // [texto, color, tamaño]
3
+
4
+ export type Escena = {
5
+ tipo: string;
6
+ p: Record<string, any>; // los campos de la escena en videos.py (colores ya en #hex)
7
+ t0: number; // inicio de la escena en el video (s)
8
+ dur: number; // duración (s)
9
+ habla: [number, number]; // cuándo empieza y termina la voz, relativo a la escena (s)
10
+ ev: {at?: number; click?: number}; // momento de la palabra `at` / `click`, relativo a la escena (s)
11
+ palabras?: [string, number, number][]; // [palabra del subtítulo, inicio, fin], relativo a la escena (s)
12
+ };
13
+
14
+ export type Subtitulo = {a: number; b: number; w: [string, number, number][]};
15
+
16
+ export type Datos = {
17
+ id: string;
18
+ titulo: string;
19
+ indice: number;
20
+ duracion: number;
21
+ estilo?: string; // 'datos-curiosos' (clásico) o un estilo del motor de capas
22
+ tema?: Record<string, any>; // ajustes del estilo para este video
23
+ hud?: Record<string, any>[]; // capas fijas sobre todo el video (día, barra de progreso, hora...)
24
+ audio: string;
25
+ whisper: number;
26
+ escenas: Escena[];
27
+ subtitulos: Subtitulo[];
28
+ };
29
+
30
+ // Identidad del canal: sale en la etiqueta de arriba y en la escena "cta".
31
+ export type Marca = {
32
+ nombre: string; // "" = sin etiqueta
33
+ logo: string | null; // ruta dentro de la carpeta public (la copia morfeo), o null = inicial del nombre
34
+ redes: string; // línea pequeña bajo el botón de la escena "cta" ("" = nada)
35
+ seguir: string; // texto del botón de la escena "cta"
36
+ siguiendo: string; // texto del botón ya pulsado
37
+ etiqueta: string; // pastilla roja de la escena "title" (se cambia por video con `tag`)
38
+ };
39
+
40
+ export const MARCA_VACIA: Marca = {nombre: '', logo: null, redes: '', seguir: '+ SEGUIR', siguiendo: 'SIGUIENDO', etiqueta: 'DATO CURIOSO'};
41
+
42
+ // Props que recibe la composición: morfeo las pasa como inputProps
43
+ export type PropsMorfeo = {videos: Record<string, Datos>; marca: Marca; fuentes?: {familia: string; url: string}[]; estilos?: Record<string, any>};
@@ -0,0 +1,153 @@
1
+ // Capa 3D con Three.js (vía @remotion/three): fondo de cubos flotando en profundidad
2
+ // e íconos pixel convertidos en voxels que giran y reciben luz.
3
+ // La cámara está calibrada para que en z = 0 una unidad sea un píxel de la pantalla:
4
+ // así los íconos se colocan con las mismas coordenadas (cx, cy) que el resto del diseño.
5
+ import {useThree} from '@react-three/fiber';
6
+ import {noise2D} from '@remotion/noise';
7
+ import {ThreeCanvas} from '@remotion/three';
8
+ import React, {useLayoutEffect, useMemo, useRef} from 'react';
9
+ import {random} from 'remotion';
10
+ import * as THREE from 'three';
11
+ import type {Icono3D} from '../escenas';
12
+ import {H, W, mezclaColor} from '../estilo';
13
+ import {ICONOS} from '../iconos';
14
+
15
+ const FOV = 30;
16
+ const DIST = H / 2 / Math.tan(((FOV / 2) * Math.PI) / 180);
17
+ const aMundo = (cx: number, cy: number): [number, number] => [cx - W / 2, H / 2 - cy];
18
+ // un objeto auxiliar para cada uso: compartirlo arrastraba escala y giro de un lado al otro
19
+ const auxVoxel = new THREE.Object3D();
20
+ const auxCubo = new THREE.Object3D();
21
+
22
+ // ---------- ícono voxel ----------
23
+ const celdasDe = (nombre: string) => {
24
+ const g = (ICONOS[nombre] ?? ICONOS.question).grid;
25
+ const out: [number, number][] = [];
26
+ g.forEach((row, r) => [...row].forEach((ch, c) => ch === '1' && out.push([c - g[0].length / 2 + 0.5, g.length / 2 - r - 0.5])));
27
+ return out;
28
+ };
29
+
30
+ const Voxel: React.FC<{ic: Icono3D; T: number}> = ({ic, T}) => {
31
+ const ref = useRef<THREE.InstancedMesh>(null);
32
+ const celdas = useMemo(() => celdasDe(ic.nombre), [ic.nombre]);
33
+ const color = ic.color ?? (ICONOS[ic.nombre] ?? ICONOS.question).color;
34
+ useLayoutEffect(() => {
35
+ const m = ref.current;
36
+ if (!m) return;
37
+ celdas.forEach(([x, y], i) => {
38
+ auxVoxel.position.set(x, y, 0);
39
+ auxVoxel.updateMatrix();
40
+ m.setMatrixAt(i, auxVoxel.matrix);
41
+ });
42
+ m.instanceMatrix.needsUpdate = true;
43
+ }, [celdas]);
44
+ const [x, y] = aMundo(ic.cx, ic.cy);
45
+ const fase = ic.fase ?? 0;
46
+ // balanceo suave + un giro completo al aparecer
47
+ const rotY = 0.38 * Math.sin(T * 1.6 + fase) + (ic.giro ?? 0) * Math.PI * 2;
48
+ const rotX = 0.14 * Math.sin(T * 1.1 + fase);
49
+ return (
50
+ <group position={[x, y, 0]} rotation={[rotX, rotY, 0]} scale={[ic.u, ic.u, ic.u]}>
51
+ <instancedMesh ref={ref} args={[undefined, undefined, celdas.length]} castShadow>
52
+ <boxGeometry args={[0.96, 0.96, 0.96]} />
53
+ <meshStandardMaterial color={color} roughness={0.45} metalness={0.05} emissive={color} emissiveIntensity={0.12} />
54
+ </instancedMesh>
55
+ </group>
56
+ );
57
+ };
58
+
59
+ // ---------- cubos de fondo ----------
60
+ const N_CUBOS = 46;
61
+ const CubosFondo: React.FC<{T: number; indice: number; glow: string}> = ({T, indice, glow}) => {
62
+ const ref = useRef<THREE.InstancedMesh>(null);
63
+ const datos = useMemo(
64
+ () =>
65
+ Array.from({length: N_CUBOS}, (_, i) => {
66
+ const r = (k: string) => random(`c3d${indice}-${i}-${k}`);
67
+ const z = -2600 * r('z') - 150; // profundidad
68
+ const escalaZ = (DIST - z) / DIST; // cuánto más grande se ve el mundo a esa distancia
69
+ return {
70
+ x: (r('x') - 0.5) * W * escalaZ * 1.1,
71
+ y0: (r('y') - 0.5) * H * escalaZ,
72
+ alto: H * escalaZ + 300,
73
+ z,
74
+ s: 14 + r('s') * 34,
75
+ v: 25 + r('v') * 70,
76
+ rx: r('rx') * 6,
77
+ ry: r('ry') * 6,
78
+ vr: 0.3 + r('vr') * 0.9,
79
+ c: Math.floor(r('c') * 4),
80
+ };
81
+ }),
82
+ [indice],
83
+ );
84
+ const colores = useMemo(() => [0.15, 0.35, 0.55, 0.75].map((k) => new THREE.Color(mezclaColor(glow, '#5A5A82', k))), [glow]);
85
+ useLayoutEffect(() => {
86
+ const m = ref.current;
87
+ if (!m) return;
88
+ datos.forEach((d, i) => {
89
+ const y = ((((d.y0 + d.v * T + d.alto / 2) % d.alto) + d.alto) % d.alto) - d.alto / 2;
90
+ auxCubo.position.set(d.x + 60 * noise2D(`c3d${indice}`, i * 0.37, T * 0.25), y, d.z);
91
+ auxCubo.rotation.set(d.rx + T * d.vr, d.ry + T * d.vr * 0.7, 0);
92
+ auxCubo.scale.setScalar(d.s);
93
+ auxCubo.updateMatrix();
94
+ m.setMatrixAt(i, auxCubo.matrix);
95
+ m.setColorAt(i, colores[d.c]);
96
+ });
97
+ m.instanceMatrix.needsUpdate = true;
98
+ if (m.instanceColor) m.instanceColor.needsUpdate = true;
99
+ });
100
+ return (
101
+ <instancedMesh ref={ref} args={[undefined, undefined, N_CUBOS]}>
102
+ <boxGeometry args={[1, 1, 1]} />
103
+ <meshStandardMaterial roughness={0.6} transparent opacity={0.85} />
104
+ </instancedMesh>
105
+ );
106
+ };
107
+
108
+ // La cámara de R3F solo toma `position` al crearse: aquí se mueve en cada fotograma.
109
+ const Camara: React.FC<{x: number; y: number}> = ({x, y}) => {
110
+ const camera = useThree((st) => st.camera);
111
+ useLayoutEffect(() => {
112
+ camera.position.set(x, y, DIST);
113
+ camera.lookAt(x * 0.3, y * 0.3, 0);
114
+ camera.updateProjectionMatrix();
115
+ });
116
+ return null;
117
+ };
118
+
119
+ // ---------- la capa completa ----------
120
+ export const Capa3D: React.FC<{
121
+ T: number;
122
+ indice: number;
123
+ glow: string;
124
+ niebla: string;
125
+ iconos: Icono3D[];
126
+ salida: number; // 0..1: la escena se va hacia arriba (misma salida que la capa 2D)
127
+ }> = ({T, indice, glow, niebla, iconos, salida}) => {
128
+ // la cámara se mece apenas, para que se sienta la profundidad
129
+ const cx = 40 * Math.sin(T * 0.35);
130
+ const cy = 30 * Math.sin(T * 0.27 + 1);
131
+ return (
132
+ <ThreeCanvas
133
+ width={W}
134
+ height={H}
135
+ style={{position: 'absolute', left: 0, top: 0}}
136
+ gl={{alpha: true, antialias: true, preserveDrawingBuffer: true}}
137
+ camera={{fov: FOV, near: 10, far: 9000, position: [0, 0, DIST]}}
138
+ onCreated={({gl}) => gl.setClearColor(0x000000, 0)}
139
+ >
140
+ <Camara x={cx} y={cy} />
141
+ <fog attach="fog" args={[niebla, DIST + 300, DIST + 2700]} />
142
+ <ambientLight intensity={0.9} />
143
+ <directionalLight position={[-600, 900, 1600]} intensity={2.4} />
144
+ <pointLight position={[0, 0, 900]} intensity={1.2} distance={4000} decay={0} color={glow} />
145
+ <CubosFondo T={T} indice={indice} glow={glow} />
146
+ <group position={[0, 70 * salida, 0]} scale={1 + 0.05 * salida}>
147
+ {iconos.map((ic, i) => (
148
+ <Voxel key={`${ic.nombre}-${i}`} ic={ic} T={T} />
149
+ ))}
150
+ </group>
151
+ </ThreeCanvas>
152
+ );
153
+ };