morfeo 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +250 -0
  9. package/lib/config.mjs +284 -0
  10. package/lib/entorno.mjs +93 -0
  11. package/lib/guion.mjs +313 -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 +312 -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 +121 -0
  40. package/remotion/motor/capas.tsx +622 -0
  41. package/remotion/motor/fondo.tsx +148 -0
  42. package/remotion/motor/modelo3d.tsx +252 -0
  43. package/remotion/motor/plantillas.tsx +125 -0
  44. package/remotion/motor/registro.ts +11 -0
  45. package/remotion/motor/subtitulos.tsx +137 -0
  46. package/remotion/motor/tema.ts +207 -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
package/index.d.ts ADDED
@@ -0,0 +1,250 @@
1
+ // Tipos de Morfeo: dan autocompletado en morfeo.config.mjs y videos.mjs (también en JS).
2
+
3
+ /** '#RRGGBB' o [r, g, b] */
4
+ export type Color = string | [number, number, number];
5
+ /** [texto, color, tamaño] — tamaño 110–260 aprox. */
6
+ export type Fila = [string, Color, number];
7
+
8
+ export type Icono =
9
+ | 'question' | 'warning' | 'building' | 'star' | 'alien' | 'cloud' | 'joystick' | 'eye' | 'tblock' | 'car'
10
+ | 'fist' | 'book' | 'computer' | 'cards' | 'coin' | 'truck' | 'skull' | 'box' | 'fog' | 'pickaxe' | 'zap'
11
+ | 'phone' | 'camera' | 'mustache' | 'cap' | 'people' | 'sword' | 'trophy';
12
+
13
+ export type EstiloMusica = 'lofi' | 'lofi2' | 'chillpop' | 'synth' | 'house' | 'ballad' | 'trap';
14
+
15
+ interface EscenaBase {
16
+ /** Lo que dice la voz. Números con letras ("mil novecientos ochenta") para que se lean bien. */
17
+ say: string;
18
+ /** Subtítulo si es distinto de `say` (p. ej. con cifras: "1980"). */
19
+ show?: string;
20
+ /** Tu propia grabación para esta escena (wav, mp3, flac, ogg). `say` sigue sirviendo para los subtítulos. */
21
+ audio?: string;
22
+ }
23
+
24
+ export type Estilo = 'datos-curiosos' | 'explicativo' | 'documental' | 'ilustrado' | 'historia' | 'mesa' | 'serie' | 'infografia' | '3d' | 'minimal' | (string & {});
25
+
26
+ /** Momento: segundos desde que empieza la escena, una palabra de `say` ("orcas"), "voz", "fin", o "palabra+0.3". */
27
+ export type Momento = number | string;
28
+ export type Entrada = 'escala' | 'rebote' | 'aparecer' | 'subir' | 'caer' | 'girar' | 'deslizar-izq' | 'deslizar-der' | 'deslizar-arriba' | 'deslizar-abajo' | 'ninguna';
29
+
30
+ interface CapaBase {
31
+ /** Centro en el lienzo 1080×1920 (dentro de un grupo o teléfono: relativo a su centro). */
32
+ x?: number;
33
+ y?: number;
34
+ ancla?: 'centro' | 'izquierda' | 'derecha';
35
+ /** Cuándo aparece y cuándo se va. */
36
+ en?: Momento;
37
+ hasta?: Momento;
38
+ entrada?: Entrada;
39
+ salida?: 'aparecer' | 'escala' | 'subir' | 'deslizar-izq' | 'deslizar-der' | 'ninguna';
40
+ duracionEntrada?: number;
41
+ escala?: number;
42
+ rotar?: number;
43
+ opacidad?: number;
44
+ mover?: {a: [number, number]; en?: Momento; duracion?: number; curva?: 'suave' | 'lineal'};
45
+ escalar?: {a: number; en?: Momento; duracion?: number};
46
+ /** Grados por segundo. */
47
+ girar?: number;
48
+ flotar?: boolean | number;
49
+ latir?: boolean;
50
+ voltear?: boolean;
51
+ desenfoque?: number;
52
+ sombra?: boolean | string;
53
+ z?: number;
54
+ /** Efecto de sonido al aparecer. */
55
+ sonido?: 'pop' | 'whoosh' | 'tick' | 'glitch' | 'ding';
56
+ /** Cambios en el tiempo (ideal para el HUD): los números se animan desde el valor anterior. */
57
+ cambios?: ({en: Momento | {escena: number}} & Record<string, unknown>)[];
58
+ }
59
+
60
+ export type Capa = CapaBase &
61
+ (
62
+ | {tipo: 'imagen'; src?: string; generar?: string; ancho?: number; alto?: number; redondo?: boolean; radio?: number; cambiarA?: {src: string; en?: Momento; efecto?: 'fundido' | 'barrido' | 'circulo'; duracion?: number}}
63
+ | {tipo: 'video'; src: string; ancho?: number; alto?: number; radio?: number}
64
+ | {tipo: 'texto'; texto: string; tam?: number; color?: string; acento?: string; fuente?: 'titulo' | 'texto' | 'mono' | (string & {}); peso?: number; mayusculas?: boolean; contorno?: number; colorContorno?: string; fondo?: string; relleno?: string; radio?: number; maxAncho?: number | 'auto'; alinear?: 'center' | 'left' | 'right'; interlineado?: number; escribir?: boolean | number; subrayado?: boolean}
65
+ | {tipo: 'contador'; de?: number; a: number; prefijo?: string; sufijo?: string; decimales?: number; miles?: string; decimal?: string; duracion?: number; curva?: 'suave' | 'lineal' | 'exponencial'; formato?: 'grande' | 'lcd' | 'chip'; tam?: number; color?: string; fondo?: string}
66
+ | {tipo: 'chip'; texto: string; unidad?: string; tam?: number; color?: string; fondo?: string; borde?: string}
67
+ | {tipo: 'etiqueta'; texto: string; tam?: number; color?: string}
68
+ | {tipo: 'numero'; texto?: string; valor?: number; tam?: number; color?: string; fondo?: string; radio?: number}
69
+ | {tipo: 'tarjeta'; titulo?: string; cabecera?: string; texto?: string; items?: (string | {texto: string; check?: Momento | boolean; en?: Momento; color?: string})[]; ancho?: number; fondo?: string; borde?: string; checks?: boolean}
70
+ | {tipo: 'telefono'; src?: string; capas?: Capa[]; ancho?: number; pantalla?: string}
71
+ | {tipo: 'forma'; forma?: 'rect' | 'circulo' | 'linea' | 'flecha'; ancho?: number; alto?: number; color?: string; borde?: string; grosor?: number; punteado?: boolean; relleno?: boolean; radio?: number; brillo?: number; transparencia?: number}
72
+ | {tipo: 'icono'; nombre: Icono; tam?: number; color?: string}
73
+ | {tipo: 'emoji'; texto: string; tam?: number}
74
+ | {tipo: 'marca'; tam?: number}
75
+ | {tipo: 'particulas'; particulas?: 'burbujas' | 'polvo' | 'estrellas' | 'nieve' | 'confeti'; cantidad?: number; color?: string}
76
+ | {tipo: 'grupo'; capas?: Capa[]; capa?: Capa; repetir?: number; disposicion?: 'circulo' | 'fila' | 'columna' | 'cuadricula' | 'pila' | 'aleatorio'; radio?: number; espacio?: number; espacioV?: number; columnas?: number; orientar?: boolean | number; retraso?: number; area?: [number, number]; girarAzar?: number; desorden?: number}
77
+ | {tipo: 'modelo'; src?: string; forma?: 'moneda' | 'billete' | 'cubo' | 'esfera' | 'lingote'; color?: string; metal?: number; rugosidad?: number; ancho?: number; alto?: number; tamano?: number; rotacion?: [number, number, number]; girar3d?: [number, number, number]; cantidad?: number; crecer?: boolean; duracion?: number; distancia?: number; camaraY?: number; luz?: number; reflejos?: number}
78
+ | {tipo: 'componente'; nombre: string; props?: Record<string, unknown>}
79
+ | {tipo: 'personaje'; nombre: string; pose?: string; poses?: {en: Momento; pose: string}[]; hablar?: boolean; ancho?: number}
80
+ | {tipo: 'escena3d'; src: string; camara?: {en?: Momento; pos: [number, number, number]; mirar?: [number, number, number]; fov?: number}[]; animacion?: boolean | string | string[]; velocidad?: number; mostrar?: {nodo: string; en: Momento}[]; ocultar?: {nodo: string; en: Momento}[]; fondo?: string | null; niebla?: [number, number]; luz?: number; luzAmbiente?: number; sol?: [number, number, number]; reflejos?: number; sombras?: boolean; ancho?: number; alto?: number}
81
+ | {tipo: 'barra'; valor: number; max?: number; titulo?: string; unidad?: string; mostrar?: boolean; color?: string; ancho?: number; alto?: number; fondo?: string; decimales?: number}
82
+ | {tipo: 'medidor'; valor: number; min?: number; max?: number; unidad?: string; tam?: number; zonas?: [number, string][]; decimales?: number}
83
+ | {tipo: 'votacion'; titulo?: string; cabecera?: string; opciones: {texto: string; valor: number; sub?: string; color?: string}[]; ancho?: number}
84
+ | {tipo: 'estado'; hora?: string; dia?: string; bateria?: number; aviso?: string; contador?: number; color?: string; ancho?: number; tam?: number}
85
+ | {tipo: 'noticia'; titular: string; etiqueta?: string; cintillo?: string; vivo?: boolean; ancho?: number; velocidad?: number}
86
+ | {tipo: 'chat'; mensajes: {usuario: string; texto: string}[]; cada?: number; maximo?: number; ancho?: number; tam?: number}
87
+ | {tipo: 'onda'; barras?: number; ancho?: number; alto?: number; color?: string; siempre?: boolean}
88
+ | {tipo: 'globo'; texto: string; cola?: 'abajo' | 'arriba' | 'izq' | 'der' | 'ninguna'; fondo?: string; color?: string; borde?: string; tam?: number; maxAncho?: number}
89
+ | {tipo: 'seguir'; usuario?: string; texto?: string; pulsar?: number}
90
+ | {tipo: 'ventana'; diametro?: number; src?: string; capas?: Capa[]; borde?: 'metal' | 'ninguno' | (string & {}); grosor?: number; remaches?: number | false; zoom?: number | [number, number]; fondo?: string}
91
+ );
92
+
93
+ export type FondoEscena =
94
+ | {tipo: 'color'; color: string}
95
+ | {tipo: 'degradado'; colores: string[]; angulo?: number}
96
+ | {tipo: 'imagen'; src?: string; generar?: string; oscurecer?: number; desenfoque?: number}
97
+ | {tipo: 'video'; src: string; oscurecer?: number};
98
+
99
+ interface EscenaMotorBase extends EscenaBase {
100
+ capas?: Capa[];
101
+ fondo?: FondoEscena;
102
+ /** Cámara: número fijo o [inicio, fin] a lo largo de la escena. */
103
+ camara?: {zoom?: number | [number, number]; x?: number | [number, number]; y?: number | [number, number]; en?: number; duracion?: number; origen?: [number, number]; temblor?: {fuerza?: number; duracion?: number}};
104
+ transicion?: 'corte' | 'fundido' | 'deslizar' | 'zoom';
105
+ /** Ajustes del tema solo para esta escena. */
106
+ tema?: Partial<Tema>;
107
+ }
108
+
109
+ /** Escenas de los estilos del motor de capas (todos menos 'datos-curiosos', que también las acepta). */
110
+ export type EscenaMotor =
111
+ | (EscenaMotorBase & {type?: 'capas'})
112
+ | (EscenaMotorBase & {type: 'titular'; texto: string; etiqueta?: string; numero?: number | string; sub?: string; y?: number; tam?: number})
113
+ | (EscenaMotorBase & {type: 'dato'; valor: number; de?: number; prefijo?: string; sufijo?: string; decimales?: number; curva?: 'suave' | 'lineal' | 'exponencial'; formato?: 'grande' | 'lcd' | 'chip'; texto?: string; imagen?: string | {generar: string}; tam?: number; y?: number; en?: Momento; duracion?: number})
114
+ | (EscenaMotorBase & {type: 'lista'; titulo?: string; cabecera?: string; items: (string | {texto: string; check?: Momento | boolean})[]; ancho?: number; y?: number})
115
+ | (EscenaMotorBase & {type: 'telefono'; src?: string; capasPantalla?: Capa[]; texto?: string; ancho?: number; y?: number})
116
+ | (EscenaMotorBase & {type: 'comparar'; a: string | {texto: string; imagen?: string; color?: string}; b: string | {texto: string; imagen?: string; color?: string}; at?: string})
117
+ | (EscenaMotorBase & {type: 'eleccion'; pregunta?: string; opciones: (string | {texto: string; sub?: string; emoji?: string; icono?: Icono; imagen?: string; color?: string; fondo?: string; en?: Momento})[]; formato?: 'cartas' | 'botones'; comenta?: string | false; enComenta?: Momento; y?: number})
118
+ | (Partial<EscenaMotorBase> & {type: 'cta'});
119
+
120
+ export interface Tema {
121
+ fondo: FondoEscena;
122
+ rayos?: {color: string; fuerza?: number};
123
+ foco?: {color: string; x?: number; y?: number; radio?: number; fuerza?: number};
124
+ vineta?: number;
125
+ textura?: 'papel' | 'grano' | 'ninguna';
126
+ particulas?: {tipo: 'burbujas' | 'polvo' | 'estrellas' | 'nieve' | 'confeti' | 'ninguna'; cantidad?: number; color?: string};
127
+ colores: {texto: string; acento: string; acento2: string; suave: string; tarjeta: string; tarjetaTexto: string; borde: string};
128
+ fuentes: {titulo: string; texto: string; mono: string};
129
+ tituloMayusculas: boolean;
130
+ subtitulos: {tipo: 'karaoke' | 'pop' | 'bloque' | 'palabra' | 'ninguno'; y: number; tam: number; color: string; pendiente: string; resalte: string; maxPalabras: number; mayusculas?: boolean; contorno?: number; fondo?: string};
131
+ transicion: 'corte' | 'fundido' | 'deslizar' | 'zoom';
132
+ marca: 'esquina' | 'arriba' | 'ninguna';
133
+ zoomLento: number;
134
+ }
135
+
136
+ export type EscenaClasica =
137
+ | (EscenaBase & {type: 'title'; rows: Fila[]; icon?: Icono; tag?: string})
138
+ | (EscenaBase & {type: 'text'; rows: Fila[]; strike?: boolean})
139
+ | (EscenaBase & {type: 'year'; pre: string; year: number; sub: string})
140
+ | (EscenaBase & {type: 'icon'; icon: Icono; label: string; color?: Color})
141
+ | (EscenaBase & {type: 'compare'; a: [string, Color]; b: [string, Color]; at: string; sizes?: [number, number]})
142
+ | (EscenaBase & {type: 'card'; file: string; rows: [string, string | null, string | null][]; at: string; flash?: string})
143
+ | (EscenaBase & {type: 'dialog'; q: [string, string]; click: string; result: string})
144
+ | (EscenaBase & {type: 'aliens'; n: number; speed: number; label?: string})
145
+ | (EscenaBase & {type: 'memory'; label?: string})
146
+ | (EscenaBase & {type: 'morph'; at?: string})
147
+ | (Partial<EscenaBase> & {type: 'cta'});
148
+
149
+ export type Escena = EscenaClasica | EscenaMotor;
150
+
151
+ /** Mezcla de voces Kokoro: [nombre o ruta .npy, peso]. */
152
+ export type MezclaVoz = [string, number][];
153
+
154
+ export type VozExterna =
155
+ | {proveedor: 'elevenlabs'; voz: string; modelo?: string; idioma?: string; ajustes?: Record<string, number | boolean>}
156
+ | {proveedor: 'openai'; voz?: string; modelo?: string; instrucciones?: string; velocidad?: number}
157
+ | {proveedor: 'kokoro'; voz: string | MezclaVoz}
158
+ | {
159
+ /** Tu propio servicio: recibe el texto y devuelve una ruta de audio, un Buffer o {datos, ext}. */
160
+ proveedor: (texto: string, opciones: Record<string, unknown>) => Promise<string | Uint8Array | {datos: Uint8Array; ext: string}>;
161
+ [opcion: string]: unknown;
162
+ };
163
+
164
+ /** Nombre de voz Kokoro ("em_alex"), alias de `voces`, archivo .npy, mezcla, o servicio externo. */
165
+ export type Voz = string | MezclaVoz | VozExterna;
166
+
167
+ /** Estilo generado, alias de `musicas`, archivo ("./musica/x.mp3"), o false (sin música). */
168
+ export type Musica = EstiloMusica | string | false | {archivo: string; volumen?: number; inicio?: number};
169
+
170
+ export interface Video {
171
+ /** Estilo visual (por defecto, el de morfeo.config.mjs). */
172
+ estilo?: Estilo;
173
+ /** Capas fijas encima de todo el video (día, barras, hora...); se actualizan con `cambios`. */
174
+ hud?: Capa[];
175
+ /** Ajustes del estilo para este video (colores, fondo, subtítulos...). */
176
+ tema?: Partial<Tema> & Record<string, unknown>;
177
+ /** Título para publicar. */
178
+ title?: string;
179
+ voice?: Voz;
180
+ music?: Musica;
181
+ /** Velocidad de la voz Kokoro (por defecto 1.2). */
182
+ speed?: number;
183
+ /** Idioma de Kokoro: "es", "en-us", "en-gb", "fr-fr", "it", "pt-br", "ja", "cmn", "hi". */
184
+ lang?: string;
185
+ scenes: Escena[];
186
+ /** Segundo del video para la miniatura (por defecto, cuando la primera escena ya está completa). Sale sin subtítulos. */
187
+ miniaturaEn?: number;
188
+ /** Datos libres que se copian a salida/resumen.json. */
189
+ meta?: Record<string, unknown>;
190
+ }
191
+
192
+ export interface Config {
193
+ /** Estilo visual por defecto: npx morfeo estilos. */
194
+ estilo?: Estilo;
195
+ /** Tus estilos: {'mi-estilo': {base: 'minimal', colores: {...}}}. */
196
+ estilos?: Record<string, Partial<Tema> & {base?: Estilo}>;
197
+ /** Componentes React propios para {tipo: 'componente'}: {Mapa: './escenas/Mapa.tsx'}. */
198
+ componentes?: Record<string, string>;
199
+ /** Personajes con poses: archivos propios o generadas con IA desde `referencia`. */
200
+ personajes?: Record<string, {referencia?: string; poses: Record<string, string>}>;
201
+ /** Fuentes propias: {'Poppins': './fuentes/Poppins.ttf'}. */
202
+ fuentes?: Record<string, string>;
203
+ /** Carpeta de imágenes/videos/modelos para las capas (por defecto ./assets). */
204
+ assets?: string;
205
+ /** Imágenes con IA para {generar: '...'}. */
206
+ imagenes?: {proveedor?: 'openai' | ((prompt: string, opciones: Record<string, unknown>) => Promise<string | Uint8Array>); estilo?: string; modelo?: string; calidad?: 'low' | 'medium' | 'high'; tamano?: string};
207
+ marca?: {
208
+ nombre?: string;
209
+ /** Imagen cuadrada (png/jpg). Sin logo se usa la inicial del nombre. */
210
+ logo?: string | null;
211
+ redes?: string;
212
+ /** Lo que dice la voz en {type: 'cta'} cuando no trae `say`. */
213
+ cta?: string;
214
+ seguir?: string;
215
+ siguiendo?: string;
216
+ /** Pastilla del gancho (escena title). */
217
+ etiqueta?: string;
218
+ };
219
+ /** Archivo con {id: video}, o carpeta con <id>.json / <id>.mjs. Por defecto ./videos.mjs + ./guiones/. */
220
+ videos?: string | Record<string, Video> | (() => Record<string, Video> | Promise<Record<string, Video>>);
221
+ /** Carpeta donde `morfeo guion` guarda los videos generados (por defecto ./guiones). */
222
+ guiones?: string;
223
+ voces?: Record<string, Voz>;
224
+ musicas?: Record<string, Musica>;
225
+ /** Carpeta de los .mp4 (por defecto ./salida). */
226
+ salida?: string;
227
+ /** gl: por defecto 'angle' (GPU) en Mac con Apple Silicon y 'swangle' (CPU) en lo demás; si la GPU falla, reintenta con CPU. */
228
+ render?: {crf?: number; gl?: 'swangle' | 'angle' | 'egl' | 'swiftshader' | 'vulkan'; navegador?: string | null; concurrencia?: number | null; miniatura?: boolean};
229
+ }
230
+
231
+ export declare function defineConfig(config: Config): Config;
232
+ export declare function defineVideo(video: Video): Video;
233
+ export declare function defineVideos(videos: Record<string, Video>): Record<string, Video>;
234
+
235
+ export interface Hecho {id: string; mp4: string; jpg: string | null; segundos: number}
236
+ type Log = (mensaje: string) => void;
237
+
238
+ export declare function render(o?: {proyecto?: string; ids?: string[]; todos?: boolean; log?: Log; alProgreso?: (id: string, progreso: number) => void}): Promise<Hecho[]>;
239
+ export declare function preparar(o?: {proyecto?: string; ids?: string[]; log?: Log}): Promise<{publico: string; datos: Record<string, unknown>}>;
240
+ export declare function lista(o?: {proyecto?: string}): Promise<{id: string; titulo: string; hecho: boolean}[]>;
241
+ export declare function studio(o?: {proyecto?: string; puerto?: number}): Promise<void>;
242
+ export declare function instalar(o?: {si?: boolean; whisper?: boolean | null; saltarNavegador?: boolean}): Promise<void>;
243
+ export declare function doctor(o?: {imprimir?: boolean}): Promise<{nombre: string; bien: boolean; detalle: string; arreglo: string}[]>;
244
+ export declare function guionConIA(o: {tema?: string; texto?: string; idioma?: string; duracion?: number; tono?: string; estilo?: Estilo; imagenes?: boolean; voz?: Voz; modelo?: string}): Promise<{id: string; video: Video}>;
245
+ export declare function guionSinIA(texto: string, o?: {id?: string; voz?: Voz; musica?: Musica; titulo?: string; estilo?: Estilo}): {id: string; video: Video};
246
+ export declare function guardarVideo(carpeta: string, id: string, video: Video): {id: string; archivo: string};
247
+ export declare function cargarEnv(proyecto: string): void;
248
+ export declare class ErrorMorfeo extends Error {}
249
+ export declare const VERSION: string;
250
+ export declare const HOME: string;
package/lib/config.mjs ADDED
@@ -0,0 +1,284 @@
1
+ // Carga y valida morfeo.config.mjs del proyecto del usuario.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import {pathToFileURL} from 'node:url';
5
+
6
+ export const NOMBRES_CONFIG = ['morfeo.config.mjs', 'morfeo.config.js'];
7
+
8
+ // Voces de Kokoro v1.0 (archivo voices-v1.0.bin)
9
+ export const VOCES_KOKORO = 'af_alloy af_aoede af_bella af_heart af_jessica af_kore af_nicole af_nova af_river af_sarah af_sky am_adam am_echo am_eric am_fenrir am_liam am_michael am_onyx am_puck am_santa bf_alice bf_emma bf_isabella bf_lily bm_daniel bm_fable bm_george bm_lewis ef_dora em_alex em_santa ff_siwis hf_alpha hf_beta hm_omega hm_psi if_sara im_nicola jf_alpha jf_gongitsune jf_nezumi jf_tebukuro jm_kumo pf_dora pm_alex pm_santa zf_xiaobei zf_xiaoni zf_xiaoxiao zf_xiaoyi zm_yunjian zm_yunxi zm_yunxia zm_yunyang'.split(' ');
10
+
11
+ /** Las voces más parecidas a un nombre mal escrito (para sugerir). */
12
+ const parecidas = (nombre, lista, n = 3) => {
13
+ const d = (a, b) => {
14
+ const m = Array.from({length: a.length + 1}, (_, i) => [i, ...Array(b.length).fill(0)]);
15
+ for (let j = 1; j <= b.length; j++) m[0][j] = j;
16
+ for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) m[i][j] = Math.min(m[i - 1][j] + 1, m[i][j - 1] + 1, m[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
17
+ return m[a.length][b.length];
18
+ };
19
+ return [...lista].sort((a, b) => d(nombre, a) - d(nombre, b)).slice(0, n);
20
+ };
21
+
22
+ export const TIPOS_CLASICOS = ['title', 'year', 'text', 'icon', 'compare', 'card', 'dialog', 'aliens', 'memory', 'morph', 'cta'];
23
+ export const TIPOS_MOTOR = ['capas', 'titular', 'dato', 'lista', 'telefono', 'comparar', 'eleccion', 'cta'];
24
+ export const TIPOS_ESCENA = [...new Set([...TIPOS_CLASICOS, ...TIPOS_MOTOR])];
25
+ export const ESTILOS = ['datos-curiosos', 'explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'serie', 'infografia', '3d', 'minimal'];
26
+ export const TIPOS_CAPA = ['imagen', 'video', 'texto', 'contador', 'chip', 'etiqueta', 'numero', 'tarjeta', 'telefono', 'forma', 'icono', 'emoji', 'marca', 'particulas', 'grupo', 'modelo', 'componente', 'barra', 'medidor', 'votacion', 'estado', 'noticia', 'chat', 'onda', 'globo', 'seguir', 'personaje', 'escena3d', 'ventana'];
27
+ export const PROVEEDORES_VOZ = ['kokoro', 'elevenlabs', 'openai'];
28
+ export const MUSICAS = ['lofi', 'lofi2', 'chillpop', 'synth', 'house', 'ballad', 'trap'];
29
+
30
+ export class ErrorMorfeo extends Error {}
31
+
32
+ /**
33
+ * WebGL por defecto: en Mac con Apple Silicon la tarjeta de video (angle) es estable y ~4 veces más rápida;
34
+ * en lo demás, por software (swangle), que funciona en cualquier computadora. Si angle falla, el render
35
+ * reintenta solo con swangle.
36
+ */
37
+ export const glPorDefecto = () => (process.platform === 'darwin' && process.arch === 'arm64' ? 'angle' : 'swangle');
38
+
39
+ /** Una carpeta de guiones: cada <id>.json o <id>.mjs es un video. */
40
+ async function leerCarpeta(dir) {
41
+ const out = {};
42
+ for (const f of fs.readdirSync(dir).sort()) {
43
+ const m = f.match(/^(.+)\.(json|mjs|js)$/);
44
+ if (!m || f.startsWith('.')) continue;
45
+ const p = path.join(dir, f);
46
+ try {
47
+ out[m[1]] = m[2] === 'json' ? JSON.parse(fs.readFileSync(p, 'utf8')) : await importar(p);
48
+ } catch (e) {
49
+ throw new ErrorMorfeo(`No pude leer ${path.relative(process.cwd(), p)}: ${e.message}`);
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+
55
+ export function buscarConfig(proyecto) {
56
+ for (const n of NOMBRES_CONFIG) {
57
+ const p = path.join(proyecto, n);
58
+ if (fs.existsSync(p)) return p;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ const importar = async (archivo) => {
64
+ // ?v= evita la caché de import() si se carga dos veces en el mismo proceso (studio, pruebas)
65
+ const mod = await import(`${pathToFileURL(archivo).href}?v=${fs.statSync(archivo).mtimeMs}`);
66
+ return mod.default ?? mod;
67
+ };
68
+
69
+ /** Carga la config y los videos del proyecto. Devuelve un objeto normalizado con rutas absolutas. */
70
+ export async function cargarProyecto(proyecto = process.cwd()) {
71
+ proyecto = path.resolve(proyecto);
72
+ const archivo = buscarConfig(proyecto);
73
+ if (!archivo) throw new ErrorMorfeo(`No encontré morfeo.config.mjs en ${proyecto}.\nCrea un proyecto con: npx morfeo nuevo mi-canal`);
74
+ let cfg = await importar(archivo);
75
+ if (typeof cfg === 'function') cfg = await cfg();
76
+ if (!cfg || typeof cfg !== 'object') throw new ErrorMorfeo(`${path.basename(archivo)} debe exportar un objeto (export default defineConfig({...}))`);
77
+
78
+ let videos = cfg.videos ?? (fs.existsSync(path.join(proyecto, 'videos.mjs')) ? './videos.mjs' : './guiones');
79
+ const carpetaGuiones = typeof videos === 'string' && fs.existsSync(path.resolve(proyecto, videos)) && fs.statSync(path.resolve(proyecto, videos)).isDirectory()
80
+ ? path.resolve(proyecto, videos)
81
+ : path.resolve(proyecto, cfg.guiones ?? 'guiones');
82
+ if (typeof videos === 'string') {
83
+ const p = path.resolve(proyecto, videos);
84
+ if (!fs.existsSync(p)) throw new ErrorMorfeo(`No existe ${videos}. Crea videos.mjs o la carpeta guiones/ (npx morfeo guion "tema")`);
85
+ videos = fs.statSync(p).isDirectory() ? await leerCarpeta(p) : await importar(p);
86
+ }
87
+ if (typeof videos === 'function') videos = await videos();
88
+ // los guiones generados con `morfeo guion` (guiones/*.json) se suman a los de videos.mjs
89
+ if (carpetaGuiones && fs.existsSync(carpetaGuiones) && videos && typeof videos === 'object')
90
+ videos = {...(await leerCarpeta(carpetaGuiones)), ...videos};
91
+ if (!videos || typeof videos !== 'object' || Array.isArray(videos)) throw new ErrorMorfeo('`videos` debe ser un objeto {id: video}.');
92
+
93
+ const m = cfg.marca ?? {};
94
+ const marca = {
95
+ nombre: m.nombre ?? '',
96
+ logo: m.logo ? path.resolve(proyecto, m.logo) : null,
97
+ redes: m.redes ?? '',
98
+ seguir: m.seguir ?? '+ SEGUIR',
99
+ siguiendo: m.siguiendo ?? 'SIGUIENDO',
100
+ etiqueta: m.etiqueta ?? 'DATO CURIOSO',
101
+ cta: m.cta ?? (m.nombre ? `Sígueme en ${m.nombre} para más.` : 'Sígueme para más.'),
102
+ };
103
+ if (marca.logo && !fs.existsSync(marca.logo)) throw new ErrorMorfeo(`No existe el logo: ${m.logo}`);
104
+
105
+ const errores = [];
106
+ const estilosPropios = cfg.estilos ?? {};
107
+ const estilo = cfg.estilo ?? 'datos-curiosos';
108
+ const assetsDir = path.resolve(proyecto, cfg.assets ?? 'assets');
109
+ const ctx = {proyecto, marca, voces: cfg.voces ?? {}, musicas: cfg.musicas ?? {}, errores, estilo, estilosPropios, assetsDir};
110
+ if (!ESTILOS.includes(estilo) && !estilosPropios[estilo]) errores.push(`estilo "${estilo}" no existe. Opciones: ${[...ESTILOS, ...Object.keys(estilosPropios)].join(', ')}`);
111
+ const componentes = {};
112
+ for (const [nombre, ruta] of Object.entries(cfg.componentes ?? {})) {
113
+ const abs = path.resolve(proyecto, ruta);
114
+ if (!/^[A-Z][A-Za-z0-9_]*$/.test(nombre)) errores.push(`componente "${nombre}": el nombre debe empezar con mayúscula (como un componente de React)`);
115
+ if (!fs.existsSync(abs)) errores.push(`componente ${nombre}: no existe ${ruta}`);
116
+ componentes[nombre] = abs;
117
+ }
118
+ const fuentes = [];
119
+ for (const [familia, ruta] of Object.entries(cfg.fuentes ?? {})) {
120
+ const abs = path.resolve(proyecto, ruta);
121
+ if (!fs.existsSync(abs)) errores.push(`fuente ${familia}: no existe ${ruta}`);
122
+ else fuentes.push({familia, archivo: abs});
123
+ }
124
+ const normalizados = {};
125
+ for (const [id, v] of Object.entries(videos)) {
126
+ if (!/^[a-z0-9][a-z0-9_-]*$/i.test(id)) errores.push(`"${id}": el id solo puede tener letras, números, - y _`);
127
+ const e = validarVideo(id, v, ctx);
128
+ if (e) normalizados[id] = e;
129
+ }
130
+ if (errores.length) throw new ErrorMorfeo(`Hay errores en tus videos:\n - ${errores.join('\n - ')}`);
131
+
132
+ const r = cfg.render ?? {};
133
+ return {
134
+ proyecto,
135
+ archivo,
136
+ marca,
137
+ videos: normalizados,
138
+ salida: path.resolve(proyecto, cfg.salida ?? 'salida'),
139
+ guiones: carpetaGuiones,
140
+ trabajo: path.resolve(proyecto, '.morfeo'),
141
+ assets: path.resolve(proyecto, cfg.assets ?? 'assets'),
142
+ estilo,
143
+ estilos: estilosPropios,
144
+ fuentes,
145
+ imagenes: cfg.imagenes ?? {},
146
+ personajes: cfg.personajes ?? {},
147
+ componentes,
148
+ render: {
149
+ crf: r.crf ?? 18,
150
+ gl: process.env.MORFEO_GL || process.env.REMOTION_GL || r.gl || glPorDefecto(),
151
+ navegador: process.env.MORFEO_NAVEGADOR || r.navegador || null,
152
+ concurrencia: r.concurrencia ?? null, // null = Morfeo elige según la máquina y el modo (ver render.mjs)
153
+ miniatura: r.miniatura ?? true,
154
+ },
155
+ };
156
+ }
157
+
158
+ const CAMPOS_ARCHIVO = new Set(['src', 'imagen']);
159
+ /** Busca `src`/`imagen` que apunten a assets/ y avisa de los que no existen. */
160
+ function revisarArchivos(x, assetsDir, err, ruta = '') {
161
+ if (Array.isArray(x)) return x.forEach((v, i) => revisarArchivos(v, assetsDir, err, `${ruta}[${i}]`));
162
+ if (!x || typeof x !== 'object') return;
163
+ for (const [k, v] of Object.entries(x)) {
164
+ if (CAMPOS_ARCHIVO.has(k) && typeof v === 'string' && !/^(https?:|data:)/.test(v)) {
165
+ const rel = v.replace(/^\.?\/?(assets\/)?/, '');
166
+ if (!fs.existsSync(path.join(assetsDir, rel))) err(`no existe assets/${rel} (lo pide ${ruta.replace(/^\./, '') || 'una capa'}.${k}). Ponlo en la carpeta assets/ o usa generar: 'descripción' para la IA`);
167
+ } else if (v && typeof v === 'object') revisarArchivos(v, assetsDir, err, `${ruta}.${k}`);
168
+ }
169
+ }
170
+
171
+ function validarCapas(capas, donde, err) {
172
+ if (!Array.isArray(capas)) return err(`${donde}: \`capas\` debe ser una lista`);
173
+ capas.forEach((c, i) => {
174
+ const d = `${donde}, capa ${i + 1}`;
175
+ if (!c || typeof c !== 'object') return err(`${d} debe ser un objeto`);
176
+ if (!TIPOS_CAPA.includes(c.tipo)) return err(`${d}: tipo "${c.tipo}" no existe. Opciones: ${TIPOS_CAPA.join(', ')}`);
177
+ if ((c.tipo === 'imagen' || c.tipo === 'video') && !c.src && !c.generar) err(`${d}: falta \`src\` (archivo en assets/) o \`generar\` (descripción para la IA)`);
178
+ if (c.capas) validarCapas(c.capas, d, err);
179
+ if (c.capa) validarCapas([c.capa], d, err);
180
+ });
181
+ }
182
+
183
+ const EXT_AUDIO = /\.(mp3|wav|flac|ogg|oga|aiff?|m4a|aac)$/i;
184
+ const pareceRuta = (x) => typeof x === 'string' && (x.includes('/') || x.includes('\\') || /\.[a-z0-9]{2,4}$/i.test(x));
185
+
186
+ /** Voz -> lista [[ref, peso]] donde ref es un nombre de voz Kokoro o la ruta absoluta de un .npy. */
187
+ function normalizarVoz(voz, ctx, err, profundidad = 0) {
188
+ if (typeof voz === 'string' && ctx.voces[voz] != null && profundidad < 5) return normalizarVoz(ctx.voces[voz], ctx, err, profundidad + 1);
189
+ if (voz && typeof voz === 'object' && !Array.isArray(voz)) {
190
+ // voz por servicio externo: {proveedor: 'elevenlabs' | 'openai' | función, ...opciones}
191
+ if (voz.proveedor ?? voz.provider) {
192
+ const proveedor = voz.proveedor ?? voz.provider;
193
+ if (proveedor === 'kokoro') return normalizarVoz(voz.voz ?? voz.voice ?? 'em_alex', ctx, err, profundidad + 1);
194
+ if (typeof proveedor !== 'function' && !PROVEEDORES_VOZ.includes(proveedor))
195
+ err(`proveedor de voz "${proveedor}" no existe. Opciones: ${PROVEEDORES_VOZ.join(', ')} o una función async (texto, opciones) => ruta o Buffer`);
196
+ return {...voz, proveedor};
197
+ }
198
+ voz = voz.archivo ?? voz.file;
199
+ }
200
+ if (typeof voz === 'string') voz = [[voz, 1]];
201
+ if (!Array.isArray(voz) || !voz.length) return err('`voice` debe ser un nombre de voz ("em_alex"), una mezcla [["em_alex", 0.6], ["am_michael", 0.4]] o un archivo .npy'), null;
202
+ const out = [];
203
+ for (const par of voz) {
204
+ const [ref, peso = 1] = Array.isArray(par) ? par : [par, 1];
205
+ if (typeof ref !== 'string' || typeof peso !== 'number') {
206
+ err(`voz inválida: ${JSON.stringify(par)}`);
207
+ continue;
208
+ }
209
+ if (ctx.voces[ref] != null && profundidad < 5) {
210
+ for (const [r, w] of normalizarVoz(ctx.voces[ref], ctx, err, profundidad + 1) ?? []) out.push([r, w * peso]);
211
+ } else if (pareceRuta(ref)) {
212
+ const abs = path.resolve(ctx.proyecto, ref);
213
+ if (!/\.npy$/i.test(abs)) err(`la voz ${ref} debe ser un archivo .npy (estilo de voz Kokoro, forma 510×1×256)`);
214
+ else if (!fs.existsSync(abs)) err(`no existe el archivo de voz ${ref}`);
215
+ out.push([abs, peso]);
216
+ } else {
217
+ if (!VOCES_KOKORO.includes(ref)) {
218
+ const alias = Object.keys(ctx.voces);
219
+ err(`la voz "${ref}" no existe. ¿Quisiste decir ${parecidas(ref, [...alias, ...VOCES_KOKORO]).map((x) => `"${x}"`).join(', ')}? En español: em_alex, ef_dora, em_santa`);
220
+ }
221
+ out.push([ref, peso]);
222
+ }
223
+ }
224
+ return out;
225
+ }
226
+
227
+ /** Música -> null | {estilo} | {archivo, volumen, inicio}. */
228
+ function normalizarMusica(m, ctx, err, profundidad = 0) {
229
+ if (m == null || m === false) return null;
230
+ if (typeof m === 'string' && ctx.musicas[m] != null && profundidad < 5) return normalizarMusica(ctx.musicas[m], ctx, err, profundidad + 1);
231
+ if (typeof m === 'string' && MUSICAS.includes(m)) return {estilo: m};
232
+ if (typeof m === 'string' && pareceRuta(m)) m = {archivo: m};
233
+ if (m && typeof m === 'object' && (m.archivo || m.file)) {
234
+ const abs = path.resolve(ctx.proyecto, m.archivo ?? m.file);
235
+ if (!fs.existsSync(abs)) err(`no existe el archivo de música ${m.archivo ?? m.file}`);
236
+ else if (!EXT_AUDIO.test(abs)) err(`formato de música no soportado: ${path.basename(abs)} (usa mp3, wav, flac u ogg)`);
237
+ return {archivo: abs, volumen: m.volumen ?? m.volume ?? 1, inicio: m.inicio ?? m.start ?? 0};
238
+ }
239
+ if (m && typeof m === 'object' && m.estilo) return normalizarMusica(m.estilo, ctx, err, profundidad + 1);
240
+ err(`music "${typeof m === 'string' ? m : JSON.stringify(m)}" no existe. Usa un estilo (${MUSICAS.join(', ')}), un archivo ("./musica/cancion.mp3"), un alias de \`musicas\` o false`);
241
+ return null;
242
+ }
243
+
244
+ function validarVideo(id, v, ctx) {
245
+ const err = (m) => ctx.errores.push(`${id}: ${m}`);
246
+ if (!v || typeof v !== 'object') return err('debe ser un objeto'), null;
247
+ if (!Array.isArray(v.scenes) || !v.scenes.length) return err('falta `scenes` (lista de escenas)'), null;
248
+ const estilo = v.estilo ?? ctx.estilo;
249
+ const clasico = estilo === 'datos-curiosos';
250
+ if (!ESTILOS.includes(estilo) && !ctx.estilosPropios[estilo]) err(`estilo "${estilo}" no existe. Opciones: ${[...ESTILOS, ...Object.keys(ctx.estilosPropios)].join(', ')}`);
251
+ const permitidos = clasico ? TIPOS_ESCENA : TIPOS_MOTOR;
252
+ const scenes = v.scenes.map((s0, i) => {
253
+ const n = `escena ${i + 1}`;
254
+ if (!s0 || typeof s0 !== 'object') return err(`${n} debe ser un objeto`), s0;
255
+ const s = {...s0, type: s0.type ?? (s0.capas ? 'capas' : undefined)};
256
+ if (!permitidos.includes(s.type))
257
+ err(
258
+ TIPOS_CLASICOS.includes(s.type)
259
+ ? `${n}: type "${s.type}" es del estilo "datos-curiosos"; en "${estilo}" usa: ${TIPOS_MOTOR.join(', ')}`
260
+ : `${n}: type "${s.type}" no existe. Opciones: ${permitidos.join(', ')}`,
261
+ );
262
+ if (s.capas != null) validarCapas(s.capas, `${n}`, err);
263
+ const say = s.say ?? (s.type === 'cta' ? ctx.marca.cta : null);
264
+ if (!say || typeof say !== 'string') err(`${n}: falta \`say\` (lo que dice la voz${s.audio ? '; con `audio` sirve para los subtítulos' : ''})`);
265
+ for (const k of ['at', 'click']) if (s[k] != null && typeof s[k] !== 'string') err(`${n}: \`${k}\` debe ser una palabra del texto`);
266
+ const e = {...s, say};
267
+ if (s.audio != null) {
268
+ const abs = path.resolve(ctx.proyecto, s.audio);
269
+ if (!fs.existsSync(abs)) err(`${n}: no existe el audio ${s.audio}`);
270
+ else if (!EXT_AUDIO.test(abs)) err(`${n}: formato de audio no soportado: ${path.basename(abs)} (usa wav, mp3, flac u ogg)`);
271
+ e.audio = abs;
272
+ }
273
+ return e;
274
+ });
275
+ // la voz sintética solo hace falta si alguna escena no trae su propia grabación
276
+ const necesitaVoz = scenes.some((s) => s && !s.audio);
277
+ let voice = null;
278
+ if (v.voice != null) voice = normalizarVoz(v.voice, ctx, err);
279
+ else if (necesitaVoz) err('falta `voice` (p. ej. "em_alex", una mezcla [["em_alex", 0.6], ["am_michael", 0.4]], un .npy, o `audio` en cada escena)');
280
+ if (v.hud != null) validarCapas(v.hud, 'hud', err);
281
+ // archivos de assets/ que piden las escenas y el HUD: mejor avisar ahora que a mitad del render
282
+ revisarArchivos({scenes, hud: v.hud, tema: v.tema}, ctx.assetsDir, err);
283
+ return {...v, estilo, voice, music: normalizarMusica(v.music, ctx, err), scenes};
284
+ }
@@ -0,0 +1,93 @@
1
+ // Rutas compartidas y detección de Python.
2
+ // ~/.morfeo (o MORFEO_HOME) guarda lo pesado, una sola vez para todos tus proyectos:
3
+ // python/ entorno virtual con kokoro-onnx, numpy, scipy...
4
+ // modelos/ voces Kokoro (~340 MB)
5
+ // whisper/ whisper.cpp + modelo (opcional, ~470 MB)
6
+ import {execFileSync} from 'node:child_process';
7
+ import crypto from 'node:crypto';
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import {fileURLToPath} from 'node:url';
12
+
13
+ export const PAQUETE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
14
+ export const VERSION = JSON.parse(fs.readFileSync(path.join(PAQUETE, 'package.json'), 'utf8')).version;
15
+ export const ES_WIN = process.platform === 'win32';
16
+
17
+ export const HOME = process.env.MORFEO_HOME || path.join(os.homedir(), '.morfeo');
18
+ export const VENV = path.join(HOME, 'python');
19
+ export const MODELOS = path.join(HOME, 'modelos');
20
+ export const WHISPER = path.join(HOME, 'whisper');
21
+
22
+ export const PY_DIR = path.join(PAQUETE, 'python');
23
+ export const REQUISITOS = path.join(PY_DIR, 'requirements.txt');
24
+ export const ENTRADA_REMOTION = path.join(PAQUETE, 'remotion', 'index.ts');
25
+ export const FUENTES = path.join(PAQUETE, 'assets', 'fuentes');
26
+
27
+ export const ARCHIVOS_MODELO = ['kokoro-v1.0.onnx', 'voices-v1.0.bin'];
28
+ export const URL_MODELOS = 'https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/';
29
+
30
+ // kokoro-onnx 0.6.1 pide Python >=3.10,<3.14
31
+ export const PY_MIN = [3, 10];
32
+ export const PY_MAX = [3, 13];
33
+
34
+ export const pythonVenv = () => (ES_WIN ? path.join(VENV, 'Scripts', 'python.exe') : path.join(VENV, 'bin', 'python'));
35
+
36
+ const huellaRequisitos = () => crypto.createHash('sha1').update(fs.readFileSync(REQUISITOS)).digest('hex').slice(0, 12);
37
+ const MARCA_VENV = () => path.join(VENV, '.morfeo-listo');
38
+ export const venvListo = () => {
39
+ try {
40
+ return fs.existsSync(pythonVenv()) && fs.readFileSync(MARCA_VENV(), 'utf8') === huellaRequisitos();
41
+ } catch {
42
+ return false;
43
+ }
44
+ };
45
+ export const marcarVenvListo = () => fs.writeFileSync(MARCA_VENV(), huellaRequisitos());
46
+
47
+ export const modelosListos = () => ARCHIVOS_MODELO.every((f) => fs.existsSync(path.join(MODELOS, f)));
48
+
49
+ /** Versión [mayor, menor] de un ejecutable de Python, o null si no existe. */
50
+ export function versionPython(cmd, args = []) {
51
+ try {
52
+ const out = execFileSync(cmd, [...args, '-c', 'import sys;print(sys.version_info[0], sys.version_info[1])'], {
53
+ encoding: 'utf8',
54
+ stdio: ['ignore', 'pipe', 'ignore'],
55
+ timeout: 15000,
56
+ });
57
+ const [a, b] = out.trim().split(/\s+/).map(Number);
58
+ return Number.isFinite(a) && Number.isFinite(b) ? [a, b] : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ const compatible = (v) => v && (v[0] > PY_MIN[0] || (v[0] === PY_MIN[0] && v[1] >= PY_MIN[1])) && (v[0] < PY_MAX[0] || (v[0] === PY_MAX[0] && v[1] <= PY_MAX[1]));
65
+
66
+ /** Busca un Python compatible (3.10–3.13). Devuelve {cmd, args, version} o null. Respeta MORFEO_PYTHON. */
67
+ export function buscarPython() {
68
+ const candidatos = [];
69
+ if (process.env.MORFEO_PYTHON) candidatos.push([process.env.MORFEO_PYTHON, []]);
70
+ const menores = [];
71
+ for (let m = PY_MAX[1]; m >= PY_MIN[1]; m--) menores.push(m);
72
+ if (ES_WIN) {
73
+ for (const m of menores) candidatos.push(['py', [`-3.${m}`]]);
74
+ candidatos.push(['python', []], ['python3', []]);
75
+ } else {
76
+ for (const m of menores) {
77
+ candidatos.push([`python3.${m}`, []]);
78
+ for (const pre of ['/opt/homebrew/bin', '/usr/local/bin', `/Library/Frameworks/Python.framework/Versions/3.${m}/bin`])
79
+ candidatos.push([`${pre}/python3.${m}`, []]);
80
+ }
81
+ candidatos.push(['python3', []], ['python', []]);
82
+ }
83
+ const vistos = [];
84
+ for (const [cmd, args] of candidatos) {
85
+ const v = versionPython(cmd, args);
86
+ if (!v) continue;
87
+ vistos.push(`${[cmd, ...args].join(' ')} → ${v.join('.')}`);
88
+ if (compatible(v)) return {cmd, args, version: v, vistos};
89
+ }
90
+ return {cmd: null, args: [], version: null, vistos};
91
+ }
92
+
93
+ export const formatoBytes = (n) => (n > 1e9 ? `${(n / 1e9).toFixed(1)} GB` : n > 1e6 ? `${Math.round(n / 1e6)} MB` : `${Math.round(n / 1e3)} KB`);