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/lib/guion.mjs ADDED
@@ -0,0 +1,313 @@
1
+ // Guiones: de un tema (la IA escribe todo) o de tu propio texto (la IA lo convierte en escenas,
2
+ // o sin IA: una escena por frase). El resultado es un video listo para editar en videos/<id>.json.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import {ErrorMorfeo, MUSICAS} from './config.mjs';
7
+
8
+ export const MODELO_GUION = 'claude-opus-5-5';
9
+ export const TIPOS_IA = ['title', 'year', 'text', 'icon', 'compare', 'card', 'dialog'];
10
+ export const ICONOS = [
11
+ 'question', 'warning', 'building', 'star', 'alien', 'cloud', 'joystick', 'eye', 'tblock', 'car', 'fist', 'book', 'computer', 'cards',
12
+ 'coin', 'truck', 'skull', 'box', 'fog', 'pickaxe', 'zap', 'phone', 'camera', 'mustache', 'cap', 'people', 'sword', 'trophy',
13
+ ];
14
+ const COLORES = {blanco: '#FFFFFF', amarillo: '#FFD400', rojo: '#FF4050', verde: '#5ADC6E', cian: '#50DCFF'};
15
+
16
+ /** id corto para el archivo: sin acentos, en minúsculas, hasta ~38 letras y sin cortar palabras. */
17
+ export const slug = (s) => {
18
+ const base = s
19
+ .normalize('NFD')
20
+ .replace(/[\u0300-\u036f]/g, '')
21
+ .toLowerCase()
22
+ .replace(/[^a-z0-9]+/g, '-')
23
+ .replace(/^-+|-+$/g, '');
24
+ if (base.length <= 38) return base || 'video';
25
+ const corto = base.slice(0, 39);
26
+ return corto.slice(0, Math.max(corto.lastIndexOf('-'), 12)).replace(/-+$/, '') || 'video';
27
+ };
28
+
29
+ const INSTRUCCIONES = `Eres guionista de videos cortos verticales (YouTube Shorts / TikTok / Reels) de 25 a 45 segundos.
30
+ Escribes en el idioma que se te pida, con frases cortas, naturales y fáciles de decir en voz alta.
31
+
32
+ Un video es una lista de escenas. Cada escena tiene "say": lo que dice la voz (una o dos frases cortas).
33
+ Los números y años en "say" van ESCRITOS CON LETRAS ("mil novecientos ochenta") para que la voz los lea bien;
34
+ en ese caso pon en "show" la misma frase con cifras ("1980") para el subtítulo. Si no hay números, "show" es null.
35
+
36
+ Tipos de escena (elige el que mejor ilustre cada frase y varíalos):
37
+ - title: el gancho, SIEMPRE la primera. "rows": 2-3 filas de texto grande; la última es el remate (color amarillo o rojo). "icon": un ícono.
38
+ - year: una fecha clave. "pre": contexto corto (p. ej. "JAPÓN"), "year": el año como número, "sub": qué pasó (2-3 palabras).
39
+ - text: 1-3 filas de texto grande con la idea clave.
40
+ - icon: un ícono grande con "label" (2-3 palabras) y "color".
41
+ - compare: antes/después o A vs B. "a" y "b": {text, color}. "at": UNA palabra de "say" en la que aparece "b".
42
+ - card: tarjeta tipo código donde un valor cambia. "file" (p. ej. "datos.txt"), "label" (comentario corto),
43
+ "card_rows": 1-2 filas {etiqueta: "ventas = ", antes: "100", despues: "0"}, "at": palabra de "say" en la que cambia, "flash": exclamación corta.
44
+ - dialog: ventana con pregunta. "q": dos líneas de pregunta, "click": palabra de "say" en la que se pulsa, "result": resultado corto.
45
+
46
+ Reglas de texto en pantalla: MAYÚSCULAS, máximo 14 letras por fila, tamaños ("size") entre 110 y 260 (más corto = más grande).
47
+ Colores disponibles: blanco, amarillo, rojo, verde, cian. Usa blanco para lo normal y un color para lo importante.
48
+ "at" y "click" deben ser palabras que aparezcan tal cual en "say".
49
+ No incluyas la escena final de "sígueme": morfeo la agrega sola.`;
50
+
51
+ /** Esquema plano (todas las claves presentes, null cuando no aplican) para la salida estructurada. */
52
+ export async function esquema() {
53
+ let z;
54
+ try {
55
+ ({z} = await import('zod'));
56
+ } catch {
57
+ throw new ErrorMorfeo('Falta la dependencia zod (npm install zod)');
58
+ }
59
+ const color = z.enum(Object.keys(COLORES));
60
+ const fila = z.object({text: z.string(), color, size: z.number()});
61
+ const escena = z.object({
62
+ type: z.enum(TIPOS_IA),
63
+ say: z.string(),
64
+ show: z.string().nullable(),
65
+ rows: z.array(fila).nullable(),
66
+ icon: z.enum(ICONOS).nullable(),
67
+ label: z.string().nullable(),
68
+ color: color.nullable(),
69
+ pre: z.string().nullable(),
70
+ year: z.number().nullable(),
71
+ sub: z.string().nullable(),
72
+ a: z.object({text: z.string(), color}).nullable(),
73
+ b: z.object({text: z.string(), color}).nullable(),
74
+ at: z.string().nullable(),
75
+ file: z.string().nullable(),
76
+ card_rows: z.array(z.object({etiqueta: z.string(), antes: z.string(), despues: z.string()})).nullable(),
77
+ flash: z.string().nullable(),
78
+ q: z.array(z.string()).nullable(),
79
+ click: z.string().nullable(),
80
+ result: z.string().nullable(),
81
+ });
82
+ return z.object({
83
+ id: z.string(),
84
+ title: z.string(),
85
+ music: z.enum(MUSICAS),
86
+ scenes: z.array(escena),
87
+ });
88
+ }
89
+
90
+ const ESTILOS_MOTOR = ['explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'minimal'];
91
+
92
+ const INSTRUCCIONES_MOTOR = `Eres guionista y director de arte de videos cortos verticales (YouTube Shorts / TikTok / Reels) de 25 a 60 segundos.
93
+ Escribes en el idioma que se te pida, con frases cortas y naturales para decir en voz alta.
94
+ Los subtítulos ya salen solos arriba: en pantalla pon solo lo que ILUSTRA la frase, no la repitas.
95
+
96
+ Cada escena tiene "say" (lo que dice la voz). Números y años en "say" van CON LETRAS; en ese caso "show" lleva la frase con cifras.
97
+ Tipos de escena:
98
+ - titular: un titular corto en "texto" (marca la palabra clave con *asteriscos*), con "etiqueta" opcional tipo "// paso 1" y "numero" opcional.
99
+ - dato: un número que cuenta hasta "valor" (con "prefijo"/"sufijo", p. ej. sufijo " m"), "texto" corto debajo. "curva": "exponencial" si crece muchísimo.
100
+ - lista: tarjeta con "titulo" y 2-4 "items"; cada item se palomea cuando la voz dice su palabra "check".
101
+ - comparar: "a" contra "b" (textos cortos); "at" es la palabra de "say" en la que aparece "b".
102
+ - capas: una ilustración. Usa "emoji" (un emoji grande que represente la idea) o, si se pide arte, "imagen_ia" (descripción breve de UN objeto o personaje aislado),
103
+ más un "chip" opcional (dato corto como "×5" o "230 dB") que aparece en la palabra "en".
104
+ - eleccion: final abierto para que el público comente: "pregunta" y 2 "opciones" ({texto, sub, emoji}). Úsala al final en series o historias.
105
+ Varía los tipos. "at", "check" y "en" deben ser palabras que aparezcan tal cual en "say".
106
+ No incluyas la escena final de "sígueme": morfeo la agrega sola.`;
107
+
108
+ export async function esquemaMotor() {
109
+ const {z} = await import('zod');
110
+ const escena = z.object({
111
+ type: z.enum(['titular', 'dato', 'lista', 'comparar', 'capas', 'eleccion']),
112
+ say: z.string(),
113
+ show: z.string().nullable(),
114
+ texto: z.string().nullable(),
115
+ etiqueta: z.string().nullable(),
116
+ numero: z.number().nullable(),
117
+ valor: z.number().nullable(),
118
+ prefijo: z.string().nullable(),
119
+ sufijo: z.string().nullable(),
120
+ curva: z.enum(['suave', 'exponencial']).nullable(),
121
+ titulo: z.string().nullable(),
122
+ items: z.array(z.object({texto: z.string(), check: z.string()})).nullable(),
123
+ a: z.string().nullable(),
124
+ b: z.string().nullable(),
125
+ at: z.string().nullable(),
126
+ emoji: z.string().nullable(),
127
+ imagen_ia: z.string().nullable(),
128
+ chip: z.string().nullable(),
129
+ en: z.string().nullable(),
130
+ pregunta: z.string().nullable(),
131
+ opciones: z.array(z.object({texto: z.string(), sub: z.string(), emoji: z.string()})).nullable(),
132
+ });
133
+ return z.object({id: z.string(), title: z.string(), music: z.enum(MUSICAS), scenes: z.array(escena)});
134
+ }
135
+
136
+ /** De la salida de la IA (motor de capas) al formato de escenas de morfeo. */
137
+ function aEscenaMotor(e, conImagenes) {
138
+ const out = {type: e.type, say: e.say};
139
+ if (e.show && e.show !== e.say) out.show = e.show;
140
+ const si = (k, v) => v != null && v !== '' && (out[k] = v);
141
+ switch (e.type) {
142
+ case 'titular':
143
+ si('texto', e.texto ?? '');
144
+ si('etiqueta', e.etiqueta);
145
+ si('numero', e.numero);
146
+ break;
147
+ case 'dato':
148
+ Object.assign(out, {valor: e.valor ?? 0});
149
+ si('prefijo', e.prefijo);
150
+ si('sufijo', e.sufijo);
151
+ si('texto', e.texto);
152
+ if (e.curva === 'exponencial') out.curva = 'exponencial';
153
+ break;
154
+ case 'lista':
155
+ si('titulo', e.titulo);
156
+ out.items = (e.items ?? []).map((it) => ({texto: it.texto, check: it.check}));
157
+ break;
158
+ case 'comparar':
159
+ Object.assign(out, {a: e.a ?? '', b: e.b ?? '', at: e.at ?? e.say.split(/\s+/).at(-1)});
160
+ break;
161
+ case 'eleccion':
162
+ si('pregunta', e.pregunta);
163
+ out.opciones = (e.opciones ?? []).slice(0, 3);
164
+ break;
165
+ case 'capas': {
166
+ const capas = [];
167
+ if (e.imagen_ia && conImagenes) capas.push({tipo: 'imagen', generar: e.imagen_ia, ancho: 640, x: 540, y: 1000, entrada: 'escala', flotar: true});
168
+ else capas.push({tipo: 'emoji', texto: e.emoji || '✨', tam: 420, x: 540, y: 1000, entrada: 'rebote', flotar: 16});
169
+ if (e.chip) capas.push({tipo: 'chip', texto: e.chip, x: 760, y: 1280, en: e.en ?? 'fin-0.5', entrada: 'rebote', sonido: 'pop'});
170
+ out.capas = capas;
171
+ break;
172
+ }
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /** De la salida plana de la IA al formato de escenas de morfeo. */
178
+ function aEscena(e) {
179
+ const c = (x) => COLORES[x] ?? x;
180
+ const out = {type: e.type, say: e.say};
181
+ if (e.show && e.show !== e.say) out.show = e.show;
182
+ switch (e.type) {
183
+ case 'title':
184
+ case 'text':
185
+ out.rows = (e.rows ?? []).map((r) => [r.text, c(r.color), Math.round(r.size)]);
186
+ if (e.type === 'title') out.icon = e.icon ?? 'question';
187
+ break;
188
+ case 'year':
189
+ Object.assign(out, {pre: e.pre ?? '', year: e.year ?? 2000, sub: e.sub ?? ''});
190
+ break;
191
+ case 'icon':
192
+ Object.assign(out, {icon: e.icon ?? 'star', label: e.label ?? '', color: c(e.color ?? 'amarillo')});
193
+ break;
194
+ case 'compare': {
195
+ const a = e.a ?? {text: '', color: 'blanco'};
196
+ const b = e.b ?? {text: '', color: 'amarillo'};
197
+ const tam = (t) => Math.max(110, Math.min(320, Math.round(1100 / Math.max(3, t.length))));
198
+ Object.assign(out, {a: [a.text, c(a.color)], b: [b.text, c(b.color)], at: e.at ?? e.say.split(/\s+/).at(-1), sizes: [tam(a.text), tam(b.text)]});
199
+ break;
200
+ }
201
+ case 'card':
202
+ Object.assign(out, {
203
+ file: e.file ?? 'datos.txt',
204
+ rows: [['// ' + (e.label ?? 'cambio'), null, null], ...(e.card_rows ?? []).map((r) => [r.etiqueta, r.antes, r.despues])],
205
+ at: e.at ?? e.say.split(/\s+/)[0],
206
+ flash: e.flash ?? '¡CAMBIO!',
207
+ });
208
+ break;
209
+ case 'dialog':
210
+ Object.assign(out, {q: (e.q ?? ['¿Seguro?', '']).slice(0, 2), click: e.click ?? e.say.split(/\s+/).at(-1), result: e.result ?? '¡LISTO!'});
211
+ break;
212
+ }
213
+ return out;
214
+ }
215
+
216
+ /**
217
+ * Pide a Claude un video completo.
218
+ * @param {{tema?: string, texto?: string, idioma?: string, duracion?: number, tono?: string, estilo?: string, imagenes?: boolean, voz?: any, modelo?: string}} o
219
+ * estilo: 'datos-curiosos' (clásico) o uno del motor de capas · imagenes: true = pedir ilustraciones con IA
220
+ */
221
+ export async function guionConIA(o) {
222
+ const perfil = fs.existsSync(path.join(os.homedir(), '.config', 'anthropic')) || process.env.ANTHROPIC_PROFILE;
223
+ if (!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_AUTH_TOKEN && !perfil)
224
+ throw new ErrorMorfeo('Para generar guiones con IA pon tu clave en el .env del proyecto: ANTHROPIC_API_KEY=...\n(Sin IA puedes convertir tu texto con: npx morfeo guion --desde guion.txt --sin-ia)');
225
+ let Anthropic, zodOutputFormat;
226
+ try {
227
+ ({default: Anthropic} = await import('@anthropic-ai/sdk'));
228
+ ({zodOutputFormat} = await import('@anthropic-ai/sdk/helpers/zod'));
229
+ } catch {
230
+ throw new ErrorMorfeo('Falta la dependencia @anthropic-ai/sdk (npm install @anthropic-ai/sdk zod)');
231
+ }
232
+ const client = new Anthropic();
233
+ const idioma = o.idioma ?? 'español de México';
234
+ const pedido = o.texto
235
+ ? `Convierte este guion en escenas. Respeta su contenido y su orden; puedes partir frases largas.\n\n<guion>\n${o.texto}\n</guion>`
236
+ : `Escribe un video sobre: ${o.tema}`;
237
+ const extra = [`Idioma: ${idioma}.`, `Duración aproximada: ${o.duracion ?? 35} segundos (unas ${Math.round((o.duracion ?? 35) / 4.5)} escenas).`];
238
+ if (o.tono) extra.push(`Tono: ${o.tono}.`);
239
+ const motor = o.estilo && o.estilo !== 'datos-curiosos';
240
+ if (motor) extra.push(`Estilo visual: ${o.estilo}.${o.imagenes ? ' Se pueden generar ilustraciones (usa imagen_ia en las escenas capas).' : ' No hay ilustraciones: usa emoji en las escenas capas.'}`);
241
+
242
+ let res;
243
+ try {
244
+ res = await client.messages.parse({
245
+ model: o.modelo ?? MODELO_GUION,
246
+ max_tokens: 16000,
247
+ system: motor ? INSTRUCCIONES_MOTOR : INSTRUCCIONES,
248
+ messages: [{role: 'user', content: `${pedido}\n\n${extra.join('\n')}\nEn "id" pon un identificador corto en minúsculas sin espacios. En "title" el título para publicar (con un emoji). En "music" el estilo de música que mejor le quede.`}],
249
+ output_config: {format: zodOutputFormat(motor ? await esquemaMotor() : await esquema())},
250
+ });
251
+ } catch (e) {
252
+ if (e instanceof Anthropic.AuthenticationError) throw new ErrorMorfeo('Tu ANTHROPIC_API_KEY no es válida.');
253
+ if (e instanceof Anthropic.RateLimitError) throw new ErrorMorfeo('Claude: límite de uso alcanzado, intenta en un momento.');
254
+ if (e instanceof Anthropic.APIError) throw new ErrorMorfeo(`Claude respondió ${e.status}: ${e.message}`);
255
+ throw e;
256
+ }
257
+ if (res.stop_reason === 'refusal') throw new ErrorMorfeo(`Claude no quiso escribir ese guion${res.stop_details?.explanation ? `: ${res.stop_details.explanation}` : ''}.`);
258
+ if (res.stop_reason === 'max_tokens') throw new ErrorMorfeo('El guion salió demasiado largo; pide una duración menor.');
259
+ const g = res.parsed_output;
260
+ if (!g) throw new ErrorMorfeo('Claude no devolvió un guion válido; intenta otra vez.');
261
+ if (motor) {
262
+ const scenes = g.scenes.map((e) => aEscenaMotor(e, !!o.imagenes));
263
+ return {id: slug(g.id || g.title), video: {estilo: o.estilo, title: g.title, voice: o.voz ?? 'em_alex', music: g.music, scenes: [...scenes, {type: 'cta'}]}};
264
+ }
265
+ const scenes = g.scenes.map(aEscena);
266
+ if (scenes[0]?.type !== 'title') scenes.unshift({type: 'title', say: scenes[0]?.say ?? g.title, rows: [[g.title.toUpperCase().slice(0, 14), '#FFD400', 200]], icon: 'question'});
267
+ return {id: slug(g.id || g.title), video: {title: g.title, voice: o.voz ?? 'em_alex', music: g.music, scenes: [...scenes, {type: 'cta'}]}};
268
+ }
269
+
270
+ /** Sin IA: una escena "text" por frase (o por línea), con la frase en pantalla partida en filas cortas. */
271
+ export function guionSinIA(texto, o = {}) {
272
+ const frases = texto
273
+ .replace(/\r/g, '')
274
+ .split(/\n+|(?<=[.!?¿¡])\s+(?=[A-ZÁÉÍÓÚÑ¿¡])/)
275
+ .map((f) => f.trim())
276
+ .filter(Boolean);
277
+ if (!frases.length) throw new ErrorMorfeo('El guion está vacío.');
278
+ const filas = (f) => {
279
+ const palabras = f.replace(/[¿?¡!.,;:]/g, '').toUpperCase().split(/\s+/).filter(Boolean);
280
+ const out = [];
281
+ let cur = '';
282
+ for (const p of palabras) {
283
+ if (cur && (cur + ' ' + p).length > 12) {
284
+ out.push(cur);
285
+ cur = p;
286
+ } else cur = cur ? `${cur} ${p}` : p;
287
+ }
288
+ if (cur) out.push(cur);
289
+ const elegidas = out.slice(0, 3);
290
+ return elegidas.map((t, i) => [t, i === elegidas.length - 1 ? '#FFD400' : '#FFFFFF', Math.max(110, Math.min(220, Math.round(1500 / Math.max(6, t.length))))]);
291
+ };
292
+ const titulo = o.titulo ?? frases[0].slice(0, 60);
293
+ const motor = o.estilo && o.estilo !== 'datos-curiosos';
294
+ const scenes = motor
295
+ ? frases.map((f) => {
296
+ // titular corto: las palabras más largas de la frase, la última resaltada
297
+ const palabras = f.replace(/[¿?¡!.,;:]/g, '').split(/\s+/).filter((w) => w.length > 3).slice(0, 3);
298
+ const txt = palabras.map((w, i) => (i === palabras.length - 1 ? `*${w}*` : w)).join(' ');
299
+ return {type: 'titular', say: f, texto: txt || f.slice(0, 24), y: 900};
300
+ })
301
+ : frases.map((f, i) => ({type: i === 0 ? 'title' : 'text', say: f, rows: filas(f), ...(i === 0 ? {icon: 'question'} : {})}));
302
+ return {id: slug(o.id ?? titulo), video: {...(motor ? {estilo: o.estilo} : {}), title: titulo, voice: o.voz ?? 'em_alex', music: o.musica ?? 'lofi', scenes: [...scenes, {type: 'cta'}]}};
303
+ }
304
+
305
+ /** Guarda el video en <carpeta>/<id>.json sin pisar uno existente (agrega -2, -3...). */
306
+ export function guardarVideo(carpeta, id, video) {
307
+ fs.mkdirSync(carpeta, {recursive: true});
308
+ let final = id;
309
+ for (let n = 2; fs.existsSync(path.join(carpeta, `${final}.json`)); n++) final = `${id}-${n}`;
310
+ const archivo = path.join(carpeta, `${final}.json`);
311
+ fs.writeFileSync(archivo, JSON.stringify(video, null, '\t') + '\n');
312
+ return {id: final, archivo};
313
+ }
@@ -0,0 +1,99 @@
1
+ // Imágenes generadas con IA para las capas. Cualquier capa o fondo de imagen puede pedir
2
+ // {tipo: 'imagen', generar: 'un oso pardo de frente, ilustración plana'}
3
+ // y Morfeo la genera una sola vez (caché en .morfeo/cache/imagenes) y la pone en assets/ia/.
4
+ // Configuración (morfeo.config.mjs):
5
+ // imagenes: {proveedor: 'openai' | async (prompt, opciones) => ruta | Buffer, estilo: 'ilustración plana, colores cálidos', modelo}
6
+ // `estilo` se agrega a cada pedido para que todas las imágenes del canal se vean iguales.
7
+ import crypto from 'node:crypto';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import {ErrorMorfeo} from './config.mjs';
11
+
12
+ const huella = (x) => crypto.createHash('sha1').update(JSON.stringify(x)).digest('hex').slice(0, 20);
13
+
14
+ async function openai(prompt, o) {
15
+ const clave = o.apiKey ?? process.env.OPENAI_API_KEY;
16
+ if (!clave) throw new ErrorMorfeo('Para generar imágenes pon OPENAI_API_KEY en el .env de tu proyecto (o usa tus propias imágenes en assets/).');
17
+ let res;
18
+ try {
19
+ res = await fetch('https://api.openai.com/v1/images/generations', {
20
+ method: 'POST',
21
+ headers: {authorization: `Bearer ${clave}`, 'content-type': 'application/json'},
22
+ body: JSON.stringify({
23
+ model: o.modelo ?? 'gpt-image-1',
24
+ prompt,
25
+ size: o.fondo ? '1024x1536' : o.tamano ?? '1024x1024',
26
+ background: o.fondo ? 'opaque' : 'transparent',
27
+ output_format: 'png',
28
+ quality: o.calidad ?? 'medium',
29
+ }),
30
+ });
31
+ } catch (e) {
32
+ throw new ErrorMorfeo(`No pude conectar con OpenAI: ${e.message}`);
33
+ }
34
+ if (!res.ok) throw new ErrorMorfeo(`OpenAI (imágenes) respondió ${res.status}: ${(await res.text()).slice(0, 300)}`);
35
+ const j = await res.json();
36
+ const b64 = j.data?.[0]?.b64_json;
37
+ if (!b64) throw new ErrorMorfeo('OpenAI no devolvió la imagen.');
38
+ return Buffer.from(b64, 'base64');
39
+ }
40
+
41
+ /** Recorre la escena y cambia cada {generar: '...'} por la ruta de la imagen generada. */
42
+ export async function generarImagenes(video, cfgImagenes, cacheDir, assetsDir, log = console.log) {
43
+ const pendientes = [];
44
+ const recorrer = (x, esFondo = false) => {
45
+ if (Array.isArray(x)) return x.map((v) => recorrer(v));
46
+ if (!x || typeof x !== 'object') return x;
47
+ const out = {};
48
+ for (const [k, v] of Object.entries(x)) out[k] = recorrer(v, k === 'fondo');
49
+ if (typeof out.generar === 'string') {
50
+ const pedido = {prompt: out.generar, fondo: esFondo || out.fondo === true};
51
+ const marca = {};
52
+ pendientes.push([pedido, marca]);
53
+ delete out.generar;
54
+ if (out.tipo) {
55
+ out.__src = marca;
56
+ return out;
57
+ }
58
+ // {generar: '...'} donde se esperaba una ruta (p. ej. imagen: {generar: '...'} en la escena "dato")
59
+ return marca;
60
+ }
61
+ return out;
62
+ };
63
+ const scenes = recorrer(video.scenes);
64
+ const hud = recorrer(video.hud ?? []);
65
+ if (!pendientes.length) return video;
66
+ const cfg = cfgImagenes ?? {};
67
+ const proveedor = cfg.proveedor ?? 'openai';
68
+ const fn = typeof proveedor === 'function' ? proveedor : proveedor === 'openai' ? openai : null;
69
+ if (!fn) throw new ErrorMorfeo(`Proveedor de imágenes "${proveedor}" no existe. Usa 'openai' o una función async (prompt, opciones) => ruta | Buffer`);
70
+ fs.mkdirSync(cacheDir, {recursive: true});
71
+ fs.mkdirSync(path.join(assetsDir, 'ia'), {recursive: true});
72
+ let nuevas = 0;
73
+ for (const [pedido, marca] of pendientes) {
74
+ const prompt = [pedido.prompt, cfg.estilo, pedido.fondo ? 'formato vertical, escena completa de fondo, sin texto' : 'objeto aislado, fondo transparente, sin texto'].filter(Boolean).join('. ');
75
+ const h = huella([typeof proveedor === 'function' ? proveedor.name : proveedor, cfg.modelo, prompt]);
76
+ const archivo = path.join(cacheDir, `${h}.png`);
77
+ if (!fs.existsSync(archivo)) {
78
+ log(` imagen: ${pedido.prompt.slice(0, 60)}...`);
79
+ const r = await fn(prompt, {...cfg, fondo: pedido.fondo});
80
+ if (typeof r === 'string') fs.copyFileSync(r, archivo);
81
+ else fs.writeFileSync(archivo, Buffer.from(r));
82
+ nuevas++;
83
+ }
84
+ fs.copyFileSync(archivo, path.join(assetsDir, 'ia', `${h}.png`));
85
+ marca.ruta = `ia/${h}.png`;
86
+ }
87
+ if (nuevas) log(` ${nuevas} imagen(es) nuevas`);
88
+ // segunda pasada: pone las rutas en su lugar
89
+ const fijar = (x) => {
90
+ if (Array.isArray(x)) return x.map(fijar);
91
+ if (!x || typeof x !== 'object') return x;
92
+ if ('ruta' in x && Object.keys(x).length === 1) return x.ruta;
93
+ const out = {};
94
+ for (const [k, v] of Object.entries(x)) if (k !== '__src') out[k] = fijar(v);
95
+ if (x.__src) out.src = x.__src.ruta;
96
+ return out;
97
+ };
98
+ return {...video, scenes: fijar(scenes), hud: fijar(hud)};
99
+ }
package/lib/index.mjs ADDED
@@ -0,0 +1,15 @@
1
+ // API de Morfeo para usar desde código:
2
+ // import {render, defineConfig, defineVideo} from 'morfeo';
3
+ export {cargarProyecto, ErrorMorfeo, MUSICAS, PROVEEDORES_VOZ, TIPOS_ESCENA} from './config.mjs';
4
+ export {HOME, VERSION} from './entorno.mjs';
5
+ export {guardarVideo, guionConIA, guionSinIA, ICONOS, MODELO_GUION} from './guion.mjs';
6
+ export {doctor, instalar, listoParaRender} from './instalar.mjs';
7
+ export {cargarEnv, lista, preparar, render, studio} from './render.mjs';
8
+ export {sintetizarVoces} from './voces.mjs';
9
+
10
+ /** Ayuda de autocompletado para morfeo.config.mjs (devuelve lo mismo que recibe). */
11
+ export const defineConfig = (config) => config;
12
+ /** Ayuda de autocompletado para un video (devuelve lo mismo que recibe). */
13
+ export const defineVideo = (video) => video;
14
+ /** Ayuda de autocompletado para videos.mjs: {id: video}. */
15
+ export const defineVideos = (videos) => videos;
@@ -0,0 +1,193 @@
1
+ // `morfeo instalar`: deja todo listo, paso a paso, preguntando antes de instalar cosas del sistema.
2
+ // `morfeo doctor`: dice qué falta sin tocar nada.
3
+ import {spawnSync} from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import readline from 'node:readline/promises';
7
+ import {
8
+ ARCHIVOS_MODELO,
9
+ ES_WIN,
10
+ HOME,
11
+ MODELOS,
12
+ PY_MAX,
13
+ PY_MIN,
14
+ REQUISITOS,
15
+ URL_MODELOS,
16
+ VENV,
17
+ buscarPython,
18
+ formatoBytes,
19
+ marcarVenvListo,
20
+ modelosListos,
21
+ pythonVenv,
22
+ venvListo,
23
+ versionPython,
24
+ } from './entorno.mjs';
25
+ import {whisperListo, instalarWhisper} from './whisper.mjs';
26
+
27
+ const ok = (m) => console.log(` ✔ ${m}`);
28
+ const no = (m) => console.log(` ✘ ${m}`);
29
+ const info = (m) => console.log(` ${m}`);
30
+ const titulo = (m) => console.log(`\n▸ ${m}`);
31
+ const tiene = (cmd) => spawnSync(ES_WIN ? 'where' : 'which', [cmd], {stdio: 'ignore'}).status === 0;
32
+ const correr = (cmd, args, opts = {}) => {
33
+ const r = spawnSync(cmd, args, {stdio: 'inherit', ...opts});
34
+ if (r.status !== 0) throw new Error(`Falló: ${[cmd, ...args].join(' ')}`);
35
+ };
36
+
37
+ async function preguntar(texto, {si = false, porDefecto = true} = {}) {
38
+ if (si) return true;
39
+ if (!process.stdin.isTTY) return porDefecto;
40
+ const rl = readline.createInterface({input: process.stdin, output: process.stdout});
41
+ const r = (await rl.question(` ? ${texto} ${porDefecto ? '[S/n]' : '[s/N]'} `)).trim().toLowerCase();
42
+ rl.close();
43
+ return r ? r.startsWith('s') || r.startsWith('y') : porDefecto;
44
+ }
45
+
46
+ const rango = `${PY_MIN.join('.')}–${PY_MAX.join('.')}`;
47
+
48
+ /** Intenta instalar Python 3.12 con el gestor del sistema, preguntando antes. */
49
+ async function instalarPython(op) {
50
+ if (process.platform === 'darwin' && tiene('brew')) {
51
+ if (!(await preguntar('¿Instalo Python 3.12 con Homebrew (brew install python@3.12)?', op))) return false;
52
+ correr('brew', ['install', 'python@3.12']);
53
+ return true;
54
+ }
55
+ if (ES_WIN && tiene('winget')) {
56
+ if (!(await preguntar('¿Instalo Python 3.12 con winget?', op))) return false;
57
+ correr('winget', ['install', '-e', '--id', 'Python.Python.3.12', '--accept-package-agreements', '--accept-source-agreements']);
58
+ info('Si el siguiente paso no encuentra Python, cierra esta terminal, abre otra y vuelve a correr: npx morfeo instalar');
59
+ return true;
60
+ }
61
+ if (process.platform === 'darwin') info('Instala Homebrew (https://brew.sh) y luego: brew install python@3.12');
62
+ else if (ES_WIN) info('Descarga Python 3.12 de https://www.python.org/downloads/ y marca "Add python.exe to PATH".');
63
+ else info('Instálalo con tu gestor de paquetes, por ejemplo: sudo apt install python3.12 python3.12-venv');
64
+ return false;
65
+ }
66
+
67
+ async function descargar(url, destino) {
68
+ const res = await fetch(url);
69
+ if (!res.ok || !res.body) throw new Error(`No pude descargar ${url} (${res.status})`);
70
+ const total = Number(res.headers.get('content-length')) || 0;
71
+ const tmp = `${destino}.part`;
72
+ const f = fs.createWriteStream(tmp);
73
+ let n = 0;
74
+ let ultimo = 0;
75
+ for await (const trozo of res.body) {
76
+ f.write(trozo);
77
+ n += trozo.length;
78
+ if (process.stdout.isTTY && Date.now() - ultimo > 250) {
79
+ ultimo = Date.now();
80
+ process.stdout.write(`\r ${path.basename(destino)}: ${formatoBytes(n)}${total ? ` de ${formatoBytes(total)}` : ''} `);
81
+ }
82
+ }
83
+ await new Promise((r, j) => f.end((e) => (e ? j(e) : r())));
84
+ if (process.stdout.isTTY) process.stdout.write('\n');
85
+ fs.renameSync(tmp, destino);
86
+ }
87
+
88
+ /**
89
+ * @param {{si?: boolean, whisper?: boolean|null, saltarNavegador?: boolean}} op
90
+ * si: responde "sí" a todo · whisper: true/false fuerza instalarlo o no (null = preguntar)
91
+ */
92
+ export async function instalar(op = {}) {
93
+ console.log(`Morfeo: preparando tu computadora (todo queda en ${HOME})`);
94
+ fs.mkdirSync(HOME, {recursive: true});
95
+
96
+ // 1) Node
97
+ titulo('Node.js');
98
+ const mayor = Number(process.versions.node.split('.')[0]);
99
+ if (mayor < 18) throw new Error(`Necesitas Node.js 18 o más nuevo (tienes ${process.versions.node}). Descárgalo de https://nodejs.org`);
100
+ ok(`Node ${process.versions.node}`);
101
+
102
+ // 2) Python + entorno
103
+ titulo(`Python ${rango} (voces Kokoro y mezcla de audio)`);
104
+ if (venvListo()) ok(`entorno listo en ${VENV}`);
105
+ else {
106
+ let py = buscarPython();
107
+ if (!py.cmd) {
108
+ no(`no encontré Python ${rango}${py.vistos.length ? ` (vi: ${py.vistos.join(', ')})` : ''}`);
109
+ if (await instalarPython(op)) py = buscarPython();
110
+ if (!py.cmd) throw new Error(`Instala Python ${rango} y vuelve a correr: npx morfeo instalar\n (o indica cuál usar con MORFEO_PYTHON=/ruta/a/python3.12)`);
111
+ }
112
+ ok(`uso ${[py.cmd, ...py.args].join(' ')} (${py.version.join('.')})`);
113
+ const vpy = versionPython(pythonVenv());
114
+ if (fs.existsSync(VENV) && (!vpy || vpy[1] !== py.version[1])) fs.rmSync(VENV, {recursive: true, force: true});
115
+ if (!fs.existsSync(pythonVenv())) {
116
+ info('creando entorno virtual...');
117
+ correr(py.cmd, [...py.args, '-m', 'venv', VENV]);
118
+ }
119
+ info('instalando paquetes (kokoro-onnx, numpy, scipy...). La primera vez tarda un par de minutos.');
120
+ correr(pythonVenv(), ['-m', 'pip', 'install', '--disable-pip-version-check', '-q', '--upgrade', 'pip']);
121
+ correr(pythonVenv(), ['-m', 'pip', 'install', '--disable-pip-version-check', '-q', '-r', REQUISITOS]);
122
+ marcarVenvListo();
123
+ ok('entorno de Python listo');
124
+ }
125
+
126
+ // 3) Modelos de voz
127
+ titulo('Voces Kokoro (~340 MB, una sola vez)');
128
+ fs.mkdirSync(MODELOS, {recursive: true});
129
+ for (const f of ARCHIVOS_MODELO) {
130
+ const p = path.join(MODELOS, f);
131
+ if (fs.existsSync(p)) ok(f);
132
+ else {
133
+ await descargar(URL_MODELOS + f, p);
134
+ ok(f);
135
+ }
136
+ }
137
+
138
+ // 4) Navegador de Remotion (Chrome Headless Shell)
139
+ titulo('Navegador para renderizar (~100 MB)');
140
+ if (op.saltarNavegador) info('omitido');
141
+ else {
142
+ const {asegurarNavegador} = await import('./navegador.mjs');
143
+ ok(await asegurarNavegador(info));
144
+ }
145
+
146
+ // 5) Whisper (opcional)
147
+ titulo('Whisper: subtítulos sincronizados palabra por palabra (opcional, ~470 MB)');
148
+ if (whisperListo()) ok('instalado');
149
+ else {
150
+ let quiere = op.whisper;
151
+ if (quiere == null) quiere = await preguntar('¿Instalo Whisper? Sin él los subtítulos se reparten por frase', {...op, porDefecto: true});
152
+ if (!quiere) info('omitido. Puedes instalarlo después con: npx morfeo instalar --whisper');
153
+ else {
154
+ if (process.platform === 'darwin' && spawnSync('xcode-select', ['-p'], {stdio: 'ignore'}).status !== 0) {
155
+ no('Whisper se compila y necesita las herramientas de Apple.');
156
+ info('Corre: xcode-select --install y cuando termine: npx morfeo instalar --whisper');
157
+ } else {
158
+ await instalarWhisper();
159
+ ok('Whisper listo');
160
+ }
161
+ }
162
+ }
163
+
164
+ console.log('\nTodo listo. Prueba: npx morfeo nuevo mi-canal && cd mi-canal && npx morfeo render');
165
+ }
166
+
167
+ /** Devuelve el estado de cada pieza; con imprimir=true lo muestra. */
168
+ export async function doctor({imprimir = true} = {}) {
169
+ const estado = [];
170
+ const add = (nombre, bien, detalle, arreglo) => estado.push({nombre, bien, detalle, arreglo});
171
+ const mayor = Number(process.versions.node.split('.')[0]);
172
+ add('Node.js', mayor >= 18, process.versions.node, 'instala Node 18+ de https://nodejs.org');
173
+ const py = venvListo() ? {cmd: pythonVenv(), version: versionPython(pythonVenv())} : buscarPython();
174
+ add(`Python ${rango}`, !!py.cmd, py.cmd ? `${py.version?.join('.')} (${py.cmd})` : 'no encontrado', 'npx morfeo instalar');
175
+ add('Entorno de Python', venvListo(), VENV, 'npx morfeo instalar');
176
+ add('Voces Kokoro', modelosListos(), MODELOS, 'npx morfeo instalar');
177
+ const {navegadorListo} = await import('./navegador.mjs');
178
+ add('Navegador de render', navegadorListo(), navegadorListo() ? 'listo' : 'falta (se descarga solo en el primer render)', 'npx morfeo instalar');
179
+ add('Whisper (opcional)', whisperListo(), whisperListo() ? 'instalado' : 'no instalado', 'npx morfeo instalar --whisper', true);
180
+ add('ffmpeg (opcional)', tiene('ffmpeg'), tiene('ffmpeg') ? 'en el PATH' : 'no está (solo hace falta para m4a/aac)', 'brew install ffmpeg', true);
181
+ for (const k of ['ANTHROPIC_API_KEY', 'ELEVENLABS_API_KEY', 'OPENAI_API_KEY'])
182
+ add(`${k} (opcional)`, !!process.env[k], process.env[k] ? 'configurada' : 'no configurada', `ponla en tu .env si la vas a usar`, true);
183
+ if (imprimir) {
184
+ console.log(`Morfeo · carpeta compartida: ${HOME}\n`);
185
+ for (const e of estado) {
186
+ const marca = e.bien ? '✔' : e.nombre.includes('opcional') ? '·' : '✘';
187
+ console.log(` ${marca} ${e.nombre.padEnd(30)} ${e.detalle}${e.bien ? '' : ` → ${e.arreglo}`}`);
188
+ }
189
+ }
190
+ return estado;
191
+ }
192
+
193
+ export const listoParaRender = () => venvListo() && modelosListos();